Skip to content

Changelog

0.12.47

Every database migration is collapsed into one v1 schema for the release (ADR-0224). The schema was built by 26 steps, 25 of them there only to upgrade databases nobody runs; a fresh database now gets every table in its final shape, and every index, from the single initial_schema step. Upgrade compatibility is promised from 1.0.0, so a database an earlier build created cannot be upgraded and must be recreated.

  • CHANGED: current_schema_version() is 1. migrations/ holds only 001_initial_schema.sql (62 tables, 4 indexes); 002 to 026 are gone, and their columns and indexes are part of the tables v1 creates, each at the position its ADD COLUMN gave it. The migration catalogs hold one upgrade step and one downgrade (which drops the schema). The v1-era table shapes, the vN_table_names lists and the rooms special case in schema.cpp are gone; core_indexes and initial_schema_indexes(), create_index_sql() and create_index_if_missing_sql() are new.
  • CHANGED: from 1.0.0 the v1 step and migrations/001_initial_schema.sql are frozen, and a schema change is a new numbered migration (migrations/AGENTS.md). A unit test requires the file and the build's v1 step to match statement for statement.
  • ADDED: a database whose schema_migrations ledger names a migration this build does not have, such as the rows 2 to 26 every build up to 0.12.46 wrote, is refused as soon as the ledger is read, before anything is migrated or hydrated, with a reason naming the row (schema_ledger_is_known). The forward-only seam (migrate_*_schema_to) refuses it too.
  • FIXED: a fresh PostgreSQL database is bootstrapped as database.migration_role when one is configured, and startup fails if it cannot be assumed. It was bootstrapped as the login role, so the migration role owned none of the v1 tables, contrary to provision-roles.sql; with every table now in v1, the first post-1.0 migration would have failed its ALTER.
  • REMOVED: the 0.11.13 upgrade fixture (tests/fixtures/upgrade/0.11.13/) and its scenarios, as ADR-0216 planned; the next fixture comes from the last 1.0.x release when 1.1 starts. The generator, the seam and the synthetic scenarios stay. docs/release-process.md requires a previous-series fixture only when a supported series starts, from 1.1 on; a pre-1.0 series start such as 0.13.0 adds none (PR #532 review).
  • TESTS (new): tests/unit/test_database_persistence.cpp: "The v1 schema step creates every table in its final shape and every index" ([collapse]), "A schema ledger naming a migration this build does not have is refused" ([collapse][security]) and "Checked-in migrations hold exactly the v1 step this build runs" ([files]). tests/unit/test_database_schema.cpp: "create_index_sql generates DDL only for registered indexes" ([ddl][security]: an unknown name, table, injected columns or injected name gets no DDL). tests/integration/test_database_upgrade_flow.cpp: "A fresh SQLite/PostgreSQL database has every column and index of the v1 schema", "A SQLite/PostgreSQL database a pre-1.0 build created is refused, not upgraded" and "A SQLite database holding tables but no schema ledger is refused, not adopted". TESTS (changed): the migration runner, PostgreSQL bootstrap, schema inventory and homeserver bootstrap scenarios expect one step at v1; the upgrade-flow seam scenarios target v1; "PostgreSQL migrations execute as the migration role, never as the login role" drops the schema instead of one step and also requires the migration role to own every table it bootstrapped; test_worker_db_uri.cpp reads quoted table names. TESTS (removed): the per-step scenarios for migrations 012, 013, 014, 015, 018 and 022, and the 0.11.13 fixture and previous-series account scenarios.

0.12.46

The documentation site takes the theme of the OMG Software organisation site (ADR-0223).

  • CHANGED: the site opens in a dark scheme of OMG near-black (#151616) with the OMG orange accent (#ff8954), whatever the system preference; the toggle offers a matching light scheme whose links and hover accent use darker oranges so they stay readable (PR #531 review). Header, tabs and footer are OMG near-black with a hairline rule in both schemes; buttons, cards and headings follow the organisation site's shapes and weights, and the home page hero carries its mono eyebrow label.
  • CHANGED: the site uses Arial and Consolas from the reader's system (font: false) and no longer loads Roboto from Google Fonts, so a page view makes no request to another origin.
  • ADDED: the OMG four-square mark as the site logo (docs/assets/omg-logo.svg) and favicon (docs/assets/omg-favicon.svg).
  • TESTS (new): tests/tooling/test_docs_site.py gains SiteThemeTests: the site opens dark with custom colours; it fetches no fonts from a third party; the logo and favicon are inert local SVG; body text, links and the hover accent meet WCAG AA contrast on the page and on cards in both schemes.

0.12.45

Sliding sync tells the client when a room's timeline is truncated. Element X, whose room list asks for timeline_limit: 1, showed only the newest message of a conversation written on another client: the server cut each timeline to its limit but sent neither MSC4186 limited nor prev_batch, so the client took the one event for the room's whole history and had no token to page back from. Opening the room raised the limit, but the server resent nothing older than the room's delta floor.

  • FIXED: a sliding sync room whose timeline holds fewer events than are visible to the user (an initial or expanded room) or than arrived since its last send (an incremental one) carries limited: true. Every non-empty timeline carries prev_batch, the stream ordering of its oldest event, which GET /rooms/{roomId}/messages?dir=b&from= pages back from without a gap or repeat; a timeline_limit of 0 gives one past the newest event withheld.
  • FIXED: when a request raises a seen room's timeline_limit past the limit the connection last built it with (or no limit is recorded), the room's latest events are resent, flagged expanded_timeline: true when any predates its last send (MSC4186 "Changes to timeline_limit"). Such a request answers without long-polling, once per request pos, so a repeat at a pos no stream advanced cannot spin the client (ADR-0221). A list that is new or whose config changed is windowed before that decision, so a sent room it newly holds with a larger limit is answered at once too (PR #530 review); an unchanged list uses its previous window. A connection keeps only the lists of its last request (MSC4186 lists are not sticky), so a list dropped and sent again is windowed afresh and list names cannot grow the connection state.
  • FIXED: num_live counts the timeline's events since the previous request (it was always 0); a room's first send on a connection has none. expanded_timeline is judged against the room's own last send, so an event that arrived after it but before the request's pos does not set it (PR #530 review).
  • CHANGED: SlidingSyncConnectionState and SlidingSyncPendingResponse record each room's effective timeline_limit (room_timeline_limits); the pending response records the expansions it answered at once (expansions_answered). sync::build_room_response takes a TimelineExpansion, and sync::timeline_limit_expands decides it from the connection.
  • TESTS (new): tests/unit/test_sliding_sync_timeline_gap.cpp ([timeline-gap]: limited and prev_batch on initial, incremental, expanded and zero-limit timelines; num_live; expanded_timeline; when a limit grows). tests/integration/test_sliding_sync_flow.cpp gains "Element X can show the latest 50 messages of a conversation another client wrote" (60 messages from another device: Element X's room list gets the newest with limited and a prev_batch that /messages pages back from) and "Opening a room in Element X sends its latest 50 messages at once" (a room_subscriptions limit of 50 on a long-poll answers at once with the latest 50, expanded_timeline, and a prev_batch to the other ten; the same request repeated long-polls). It also gains "A list added on a long-poll that grows a sent room's timeline_limit sends its history at once" and "A list dropped and sent again unchanged is windowed afresh before the long-poll decision". TESTS (changed): "MSC4186 timeline_limit caps the number of timeline events returned per room" looked for a /v3/sync-shaped timeline.events object and so asserted nothing; it now requires the timeline array to hold exactly one event.

Every process also logs the libraries it runs at startup, each marked static or dynamic (ADR-0222).

  • ADDED: startup INFO lines Library <name> <version> (static) and Library <name> <version> (dynamic, <shared object>; built against <version>) for libsodium, OpenSSL, libpq, SQLite, libcurl and yyjson, in the server log (and --dry-run) and, prefixed Federation worker library, in the federation worker's. Linkage is read from the loader (dladdr(3)), so it is what the process runs; a dynamic library's line names the shared object and the version the binary was built against.
  • ADDED: the server asks the thumbnail worker once at startup for its libpng and libjpeg-turbo versions and logs them as Thumbnail worker library …, or why it could not (Thumbnail worker libraries not reported: …, a warning when thumbnailing is enabled). The request (MLQ1, answered MLR1) takes the same sandboxed spawn as a thumbnail, and the worker answers it after its hardening. merovingian-thumbnail-worker --version prints the same lines. libjpeg-turbo has no runtime version call, so it reports the version it was built against.
  • CHANGED: media::generate_thumbnail's spawn and exchange moved, unchanged, into exchange_with_worker, which the library report shares. core::LibraryVersion, core::describe_library and core::library_version_log_line are new; each owning module reports its library (crypto, database, http, canonicaljson::library_versions, homeserver::linked_library_versions). Meson gains dl_dep, and passes libpq's and libjpeg-turbo's pkg-config versions, whose headers carry none.
  • TESTS (new): tests/unit/test_library_versions.cpp ([library-versions]: static detection, every server library reported with its linkage, log line forms, libpq and yyjson version numbers, the worker report's framing and refusals). tests/integration/test_media_thumbnail_flow.cpp gains "the sandboxed worker reports the image libraries it runs" and "a thumbnail worker that cannot run reports no libraries". Smoke: server-library-versions-log-smoke-test and thumbnail-worker-version-smoke-test (which, in a -Db_coverage=true build, checks only the output: the decoder sandbox refuses the coverage data written at exit, so the worker ends with SIGSYS after printing).
  • CHANGED: the README shows a CodeRabbit pull-request reviews badge, linked to coderabbit.ai, beside the code-scanning badge.

0.12.44

Email pushers are delivered. With an SMTP relay configured (server.email.*), kind: "email" pushers send a delayed, throttled digest of the unread count to the account's own email address; without one they are refused as before (ADR-0219, ADR-0220).

  • ADDED: server.email.* config: smtp_url (smtps:// or smtp:// with mandatory STARTTLS), require_tls (false only for a cleartext relay at a loopback IP literal, never with credentials), from, username and password_file (an owner-only secret file read for each email), connect and total timeouts, notification_delay_seconds (600) and notification_throttle_seconds (3600). Every key requires a restart; startup refuses a relay whose scheme the linked libcurl cannot speak.
  • ADDED: src/email/, a module for outbound email: is_safe_mailbox_address (refuses, never repairs, any address that could inject an SMTP command or header), parse_smtp_url, build_unread_notification_email (RFC 5322, US-ASCII, the unread count and server name only) and send_smtp_message (one libcurl SMTP session: TLS 1.2 or later, verification on, no proxy, no connection reuse).
  • ADDED: push::EmailDigestQueue and the email-push job thread (homeserver/email_push.cpp). A notification schedules one email per pusher after the delay, never sooner than the throttle after the last; more notifications fold into it; an email that finds nothing unread when due is not sent. A failed send is retried after 1, 2, 4, 8 and 16 minutes and given up at the sixth failed attempt, about half an hour after the first. The queue is in memory and holds at most 4096 pushers.
  • CHANGED: POST /_matrix/client/v3/pushers/set accepts kind: "email" when smtp_url is set, with app_id m.email (spec) and a pushkey that is an email 3PID already on the account (compared case-insensitively); anything else is 400 M_INVALID_PARAM. Without a relay the 0.12.3 refusal is unchanged. Deleting an email pusher also drops any email due to it, and a kind: null delete for app_id m.email matches the address in any letter case, so every email pusher for that address goes (found in review). Every other app_id still matches exactly.
  • CHANGED: POST /_matrix/client/v3/account/3pid/delete deletes the account's email pushers for the address it removes, and answers 500 M_UNKNOWN instead of 200 when the 3PID or one of those pushers could not be deleted (the 3PID's result was discarded before; found in review). A retry finishes the removal. The email job re-checks the pusher, the 3PID and the account before each send, deletes a pusher whose address has left the account, and never mails a deactivated account.
  • CHANGED: push delivery computes the Push Gateway badge count only when an http pusher will carry it.
  • SECURITY: the federation worker clears server.email in its config copy, so no worker code path can send email or open the SMTP password file. server.email.password_file is also in platform::worker_landlock_secret_paths, so the worker refuses to start when a Landlock rule (such as the SQLite database directory grant) would cover it (found in review).
  • CHANGED: src/meson.build builds http, platform and the new email module before config, whose validation calls email's address and URL checks.
  • FIXED (found by this PR's CI): src/email/smtp_endpoint.cpp did not compile on FreeBSD or OpenBSD: it relied on glibc's <arpa/inet.h> declaring AF_INET/AF_INET6, which POSIX puts in <sys/socket.h>. It now includes <sys/socket.h>.
  • FIXED (found by this PR's CI): test_email_push_flow.cpp failed under ASan. The mock relay records a session after answering QUIT, and libcurl returns as soon as it reads that answer, so the test could check for a delivered email before the relay thread had recorded it. Every assertion that a session happened now waits, at most ten seconds, for it to be recorded, and every assertion that the relay was never contacted reads a count of connections taken as accept() returns (found in review: the recorded sessions could miss one still in progress).
  • FIXED (found by this PR's CI): CodeQL flagged three long functions with few comments (cpp/poorly-documented-function). send_smtp_message now applies its libcurl options through a configure_session helper whose groups each name the ADR-0220 rule they enforce, and two long test scenarios are split into accepted and refused cases.
  • FIXED (found by this PR's CI): rhel-build-and-test failed every email scenario that needs an SMTP session: AlmaLinux 10 links libcurl-minimal, built without SMTP, so no session could start. The server already refuses to start with such a relay; the scenarios now skip, naming the reason, when the linked libcurl lacks the protocol. docs/user-manual.md, docs/platform-support.md and docs/dependencies/libcurl.md tell RHEL operators to install the full libcurl for email.
  • TESTS (new): tests/unit/test_email.cpp, tests/unit/test_push_email_digest_queue.cpp, tests/unit/test_config_email.cpp, tests/integration/test_email_push_flow.cpp (real SMTP sessions against a loopback mock relay: implicit TLS with AUTH, STARTTLS, a relay without STARTTLS, cleartext loopback; read-before-due; address removed; pusher deleted with an email due; deactivated account; unreadable password file; stop requested mid-batch). TESTS (changed): tests/conformance/test_client_server_conformance.cpp gains the relay-configured [email-pusher] scenario; tests/unit/test_worker_landlock.cpp gains an SMTP password file beside the SQLite database scenario, and the shipped layout lists seven secrets.
  • DOCS: ADR-0219, ADR-0220; docs/user-manual.md (server.email.*), config/merovingian.conf.example, docs/architecture.md, docs/threat-model.md, docs/observability-audit.md (push.email.* events), docs/security-coding-rules.md, docs/dependencies/libcurl.md, src/email/AGENTS.md. docs/todos/production-milestone.md no longer lists email pushers as a 1.0.0 blocker: the refusal in 0.12.3 already met that row, which stayed listed by mistake. docs/todos/capability-gaps.md marks email pushers runtime-wired.

0.12.43

Meson wrap pins brought up to date; unused wraps for OS-supplied dependencies removed.

  • CHANGED: subprojects/sqlite3.wrap pins SQLite 3.54.1 (WrapDB 3.54.1-1, was 3.53.1), subprojects/yyjson.wrap yyjson 0.13.0 (was 0.12.0), subprojects/catch2.wrap Catch2 3.16.1 (was 3.14.0) and subprojects/curl.wrap curl 8.23.0 (was 8.20.0; used only by the static musl build). Every source_hash and the SQLite patch_hash were computed from the downloaded archives; the SQLite and curl archives were identical from both mirrors, and the curl tarball's signature verifies against Daniel Stenberg's key.
  • CHANGED: meson.build accepts a system yyjson only at >=0.13.0 (was >=0.12.0). yyjson 0.13.0 fixes an uninitialised read and an integer truncation in number parsing, both reachable from untrusted canonical JSON. The adapter needed no change: 0.13.0 keeps the read API and error codes 0-14, and its new YYJSON_READ_ERROR_DEPTH only fires when YYJSON_READER_DEPTH_LIMIT is set, which it is not; the canonical JSON parser keeps enforcing its own 64-level limit.
  • REMOVED: subprojects/openssl.wrap (OpenSSL 3.0.20, a line upstream no longer supports), subprojects/libsodium.wrap, subprojects/libpq.wrap (PostgreSQL 18.0) and their subprojects/packagefiles/ directories. These dependencies are allow_fallback: false, so no build ever fetched them (ADR-0218).
  • FIXED: a committed include/openssl/configuration.h, generated from an OpenSSL 3.0.20 wrap build, shadowed the OS package's own header, because every target compiles with -I include ahead of the system include path. It is deleted, and a tooling test keeps include/openssl/ from coming back.
  • FIXED (found by this PR's CI): scripts/check-startup-hardening-gate.sh could fail a healthy server with "server exited with status on SIGTERM". Its wrapper wrote the exit status with a redirect, which creates the file before writing the number, and the gate stops waiting as soon as the file exists. The status is now written to a temporary file and renamed into place.
  • FIXED: the curl packagefile passed --without-librtmp, an option curl removed with RTMP support in 8.20.0; autoconf only warned about it. It is dropped.
  • CHANGED: the curl source directory in tests/meson.build and scripts/validate-phase1-config.sh moves to curl-8.23.0, and the tooling tests now read it from curl.wrap instead of hard-coding a version. .gitignore ignores extracted wrap sources of any version.
  • DOCS: docs/dependencies/ records the new pins; openssl.md states that the OS OpenSSL must be a branch that still receives security fixes (3.5 LTS or newer, or a vendor-backported older branch); libcurl.md no longer claims the static build uses Alpine's curl-static, which it cannot (-Dstatic_curl_wrap=true).

0.12.42

Production milestone blocker: a database written by an older release is now opened, migrated and served by the candidate, in CI (ADR-0216).

  • ADDED: tests/fixtures/upgrade/0.11.13/, a SQLite database and a PostgreSQL dump written over HTTP by release 0.11.13 (the last of the 0.11 series, schema version 11), with a manifest of what was stored. tests/integration/test_database_upgrade_flow.cpp starts the current runtime on each and reads everything back: old access tokens and passwords, messages, aliases, media bytes, profile, account data, pushers, device keys, an old sync token, a retried send replaying the event the old release stored for that transaction ID, and a new event (under a transaction ID the old release never saw) whose signature verifies against the signing key the old release stored, so a runtime that silently signed with a replacement key would fail. scripts/generate_upgrade_fixture.py rebuilds a fixture from an old release's commit; docs/release-process.md says when. It fails rather than writing a fixture that lacks anything the test reads back (signed device keys need the cryptography package).
  • ADDED: database::migrate_sqlite_schema_to and database::migrate_postgresql_schema_to build a schema at an older version and stop there, so tests can seed that version's rows and open every older version at the current one. They only move forward, refuse a version this build does not know before touching the database, and refuse a schema already past the target. Both migration runners now take a target version and refuse a state already past it, so a racing process can never turn a migration into a downgrade plan. Tests also pin the failure paths: a schema the migrations do not expect (a column a later migration adds already present) stops the upgrade with the version unchanged and the store refusing to open, on both backends.
  • FIXED (found by the upgrade test): the PostgreSQL store wrote profiles rows but never loaded them, so every displayname and avatar disappeared from the API at each restart. It now loads them like the SQLite store.
  • FIXED (found by the upgrade test): both stores read the migration ledger ordered by its TEXT version column, so '10' sorted before '9' and a reloaded ledger's last entry was not the newest migration. The ledger is now read in numeric order.
  • CHANGED (coverage): gcovr runs with --exclude-throw-branches (ADR-0217). The unwinding edge the compiler adds to every call that may throw made executed lines read as partial, since only an injected allocation failure reaches it. Decision branches still count. The flag needs GCC's gcov (clang's llvm-cov gcov does not label those edges, so on clang it excluded nothing), so the coverage job alone now builds with gcc-14/g++-14 and reads its data with gcov-14; every other job stays on clang. Branch figures change project-wide in the first report after this because the measurement changed, not the tests. tests/tooling/test_coverage_workflow.py pins the flag, the compiler and the gcov executable as real configuration lines, so a comment cannot satisfy the check and none of the three can change alone.
  • FIXED (first GCC build): the 30 static_cast<void>(…) / (void)… discards left in the tests are now std::ignore = …, as the coding rules require. GCC, unlike clang, does not let a void cast silence warn_unused_result (sodium_init, POSIX write), so -Werror stopped the GCC coverage build on them. GCC also rejected (-Wtype-limits) a conformance assertion that could never fail: REQUIRE(envelope->depth >= 0U) on an unsigned field, under "depth is populated". It now requires the fixture PDU's actual depth (== 5U), so it tests what it claims. A local GCC 14 build with ninja -k 0 found no other GCC-only error.
  • TEST DIAGNOSTIC: "DB-6 PostgreSQL inbound admission rejects escaped NUL state keys" failed once in CI (captured_statements not empty after a rejected PDU) after passing on nine earlier heads and four local runs. Its check now reports the names of the statements written, so the next failure identifies the leaking write instead of only saying one happened.
  • ADDED (gate): scripts/reject-unsafe.sh rejects void casts (static_cast<void>(…) and C-style (void)expr) in C++ files under include/, src/ and tests/, so the rule is enforced at commit time and in CI, not just written down. Empty parameter lists (int f(void)), function types (std::function<void(int)>), comments and text inside string or character literals are not flagged, and a // inside a literal (a URL) does not hide a later cast; single-line raw literals (R"tag(...)tag") are read whole the same way; C files are out of scope (C has no std::ignore). tests/tooling/test_reject_unsafe.py (reject-unsafe-tooling) runs the gate on planted files; it also rejects every void cast that was on main before this change.
  • FIXED (gate passed without checking on the BSDs): every reject-unsafe.sh rule used grep -P, which the NetBSD, FreeBSD and OpenBSD greps do not have. Each such grep exited 2, the rules read that as "no match", and the gate passed on those hosts while checking nothing (CI enforcement was unaffected: the gate itself only runs on the Ubuntu jobs). The gate now uses grep, else ggrep (GNU grep on the BSDs), and refuses to run (exit 2) when neither has -P. Found when its new tooling test failed on the NetBSD VM; the rule tests now skip on a host without such a grep, and a test that puts a -P-less grep on PATH checks the refusal everywhere.
  • KNOWN, not carried over: 0.11 listed every room whose join rule was public in /publicRooms. Since migration 018 a room is listed only when published, so a room upgraded from 0.11 is unlisted until a room administrator publishes it again. There are no deployments yet and the migrations collapse into one at 1.0.0, so no backfill was added (owner decision, ADR-0216).

0.12.41

Media part of the conformance audit (2026-10-17): R17 and the wave-6 media endpoints.

  • FIXED (R17): GET /_matrix/client/v1/media/download/{serverName}/{mediaId}/{fileName} and the deprecated /_matrix/media/v3/download/.../{fileName} answered 404 for media that existed: the local router refused the third path segment. They now serve the media, and Content-Disposition names the requested file, as the spec requires. A name is written as an RFC 6266 quoted string (printable ASCII) or filename*=utf-8'' percent-encoding, and a name with a control character, a path separator or invalid UTF-8 is left out (media::media_content_disposition, ADR-0211). The deprecated named route stays frozen for new media (ADR-0068). Named downloads have their own rate-limit bucket (.../download/{server}/{mediaId}/{fileName}).
  • FIXED (found while fixing R17): an upload's filename (?filename=) was discarded, so a download's Content-Disposition never carried it, although the spec says it "MUST contain the same filename". It is now stored (media.upload_name) and served back; a remote server's Content-Disposition filename is kept with its copy. An upload whose filename has a control character, a path separator, invalid UTF-8 or more than 255 bytes is refused with 400 M_INVALID_PARAM (ADR-0211).
  • FIXED (found while fixing R17): an upload over a quota answered 507 M_LIMIT_EXCEEDED and a file type the server does not permit 415; the spec answers both with 403 M_FORBIDDEN. Both upload routes now do, and an oversized upload is 413 M_TOO_LARGE.
  • ADDED (wave 6, approved): asynchronous uploads. POST /_matrix/media/v1/create reserves an mxc:// URI (unused_expires_at, 24 hours by default); PUT /_matrix/media/v3/upload/{serverName}/ {mediaId} fills it once (409 M_CANNOT_OVERWRITE_MEDIA, 404 for an unknown or expired ID, 403 for another user's). Reservations are durable rows, capped per user (10) and server-wide (10000), and create is rate limited. A download of a reserved ID waits up to min(timeout_ms, pending_upload_max_wait_ms) on the media fetch pool, never on a main-pool thread or under runtime.mutex, then answers 504 M_NOT_YET_UPLOADED; the federation route answers 504 at once (ADR-0212). New config keys security.media.pending_upload_ttl_seconds, max_pending_uploads_per_user, max_pending_uploads, pending_upload_max_wait_ms.
  • ADDED (wave 6, approved; off by default): GET /_matrix/client/v1/media/preview_url and the deprecated v3 route, behind security.media.url_preview_enabled=false. Every hop (page, redirects, og:image) is resolved, refused if any address is private, loopback, link-local, CGNAT, reserved or in deny_ip_ranges, and pinned to the checked addresses; redirects are followed by the gate (at most 3), within one deadline (10 s) and byte caps (1 MiB page, 5 MiB image). HTML is scanned by a bounded og:*/<title> extractor outside the runtime lock; the image is stored through the upload pipeline; every failure is the same 502. Previews run on the media fetch pool, are rate limited per user, and are cached by URL (ADR-0213, ADR-0214).
  • Review fixes (PR #524): a download waiting for a pending upload is admitted first: at most media_fetch_threads - 1 waits at once (half for one requester), and on a request thread an ADR-0079 client-outbound slot too; otherwise 504 M_NOT_YET_UPLOADED at once, so waits cannot fill the media fetch pool or hold main-pool workers. An upload the server fails to store answers 500 M_UNKNOWN, not M_BAD_REQUEST. The stored filename crosses from the local router to the client API in a response field, not an internal header. UrlPreviewCache cannot be copied (its index holds iterators into its own list). URL previews use the shared UTF-8 validator.
  • Migration 026_media_upload_names_and_pending_uploads.sql (schema v26): media.upload_name and the media_pending_uploads table. It follows wave 3's 025_appservice_directory.sql.
  • FIXED (PR #524 review): with server.http.media_fetch_threads = 1 a pending-upload wait could take the media fetch pool's only worker. A single-worker pool now admits no wait: a download of a reserved, empty media ID answers 504 M_NOT_YET_UPLOADED at once.

0.12.40

Conformance audit wave 5 (push): R19, R15 and R14 of docs/conformance-audit-report-2026-10-17.md.

  • FIXED (R19, push-rule mutation APIs absent): PUT and DELETE /pushrules/global/{kind}/{ruleId}, PUT .../enabled and PUT .../actions are implemented. A new rule is enabled and becomes the most important user-defined rule of its kind unless before/after place it (before wins when both are given). Rule IDs starting with . (reserved for server defaults), containing / or \, or (room and sender rules) not a room or user ID are refused with 400 M_INVALID_PARAM; a placement that names no rule answers 400 M_UNKNOWN, and one relative to a server-default rule 400 M_INVALID_PARAM. Server-default rules can be enabled, disabled and given new actions but not created or deleted. Historical dont_notify and coalesce actions are stripped. A user's rules are kept as their m.push_rules account data and applied over the current server defaults (ADR-0204), so a change reaches the user's clients in the account_data section of /sync, and push delivery now evaluates the user's own rules instead of the read-only defaults. PUT /user/{userId}/account_data/m.push_rules and .../m.fully_read are refused with 405, as the spec requires for server-managed types. The conformance scenarios that asserted 404 M_UNRECOGNIZED for these endpoints now assert the spec's answers.
  • SECURITY (bounded work under the runtime lock): rules are evaluated under runtime.mutex for every recipient of every event, so a user holds at most server.client_api.max_push_rules_per_user user-defined rules (new, default 200, at most 10000; 400 M_TOO_LARGE past it, updates always allowed), and each rule is bounded: an ID of 255 bytes, patterns and condition strings of 512 bytes, 16 conditions, 16 actions, 4096 bytes of canonical JSON. Stored rules are re-validated when read.
  • FIXED (R15, unread counts ignored push rules): every unread count (/sync unread_notifications and unread_thread_notifications, sliding-sync counts and the by_notification_count sort, the Push Gateway badge) is now decided by the user's own push rules (ADR-0205): an event counts as a notification when the rules notify, and as a highlight when they notify with the highlight tweak. A muted room no longer counts, a keyword rule highlights, and a mention the rules do not notify for no longer counts as a highlight. sync::count_unread_by_thread stays the one walk and asks a per-request sync::UnreadEventClassifier; delivery and counting build their evaluation context from the same push_room_facts helpers.
  • FIXED (audit concern 1, with R15): unread counts no longer include events from before the user's current join, which their rules were never run for and whose content a keyword rule could otherwise probe.
  • FIXED (R14, failed push notifications never retried): a notification the gateway answers with an HTTP error, or cannot be reached for, is sent again after 5 s, doubling to at most 10 minutes, and given up after 8 retries (ADR-0206). Retries wait in a bounded in-memory queue (1024 notifications, 32 per recipient) drained by one push-retry job thread, never on a request thread and never with runtime.mutex held across the network call. A pushkey the gateway rejects loses its pusher and its queued retries; a retry whose pusher was removed or re-pointed is dropped. A notification skipped because its gateway's circuit is open is now retried instead of dropped. New log events: push.delivery.retry_scheduled, push.delivery.retry_abandoned (debug), push.delivery.retry_dropped and push.delivery.gave_up (warning).
  • FIXED (PR #523 review): delivery loads each recipient's push rules once (the badge count reused a second load under runtime.mutex); sliding sync builds its unread classifier only after the M_UNKNOWN_POS and long-poll returns; a push retry that fails late in a long batch is scheduled from when it failed, not from when the batch began; and the log-field and capability-gap docs match the code.
  • SECURITY (PR #523 review, bounded work under the runtime lock): the push badge (counts.unread) re-ran the exact per-room unread walk for every joined room, each scanning the whole store, under runtime.mutex for every recipient of every event. count_push_badge now makes one walk of at most 10000 rows and saturates at 1000 (ADR-0207; see the stream-order entry below for how the walk runs). /sync unread counts stay exact. Rejected and soft-failed events no longer count as unread, and a rejected leave no longer ends a user's join for counting.
  • FIXED (PR #523 review, spec rate limits): the push-rule endpoints the spec documents as "Rate-limited: No" (all GET, DELETE, and PUT .../enabled and .../actions) could be answered 429; they now bypass the rate limiter. PUT /pushrules/global/{kind}/{ruleId} ("Rate-limited: Yes") keeps its bucket, including for a rule named enabled or actions.
  • SECURITY (PR #523 review, unread counts and history visibility): an event the timeline withholds, such as one backfilled from before the user's join and stored after it, was still counted (and highlighted by keyword rules), revealing something about history the user may not read. Every counted event now passes the same HistoryVisibility filter as the timeline (ADR-0205).
  • SECURITY (PR #523 review, per-room table walks under the runtime lock): the unread classifier walked the whole memberships, current state and events tables again for each room it was asked about. In /sync this happened per room for every request; in the push badge it happened for every recipient of every event. A capped badge also walked all of the user's read receipts again for each room. The classifier now builds its room facts and every room's join start once per request: one walk each of the memberships, the current state and the events, plus the user's profile. Events are looked up by ID (ADR-0215). /sync, sliding sync and the badge give the same counts as before. The badge's setup is still linear in the server's memberships and receipts tables, once per badge; per-user indexes on them are a follow-up (ADR-0207).
  • FIXED (PR #523 review, string notification levels): push evaluation read a room's notifications levels only when they were JSON integers. Room versions 1-9 allow a level written as a string ("This includes the nested values within the events, notifications and users properties", rooms/v9.md), so a room that set "room": "100" fell back to the default of 50, and a sender at 50 could notify everyone with @room. Each entry is now read with events::decode_power_level, the decoder the authorization rules use, in the room version's encoding. An entry the rules would not read, such as a string in version 10 or later, still falls back to the default.
  • FIXED (PR #523 review, a capped badge could count what /sync does not): the badge's walk followed the order rows were stored in, which is not stream order. A capped walk could reach a message without reaching the user's later own message in the same thread, and count a message they had read. The walk now runs newest first in true stream order over the indexed event table (ADR-0215). It resolves each read receipt's position by event ID before it starts, and looks thread parents up by ID at no cost to its budget. It stops as soon as every joined room is settled: past a non-join membership change, or at a join under the unthreaded read marker. An uncapped badge equals /sync's count. A capped one counts only events above the lowest row visited, so it never counts one /sync would not (ADR-0207, amended).
  • CHANGED (PR #523 review, one history filter per request): build_room_response's convenience overload built a HistoryVisibility for the request and then a classifier that built a second one of its own. The classifier now borrows the request's filter, so the two share one set of lazy indexes. Counts are unchanged.
  • DOCS (PR #523 review): docs/threat-model.md said unread counts do not go through the history visibility filter and named only the join boundary as the mitigation. It, the security coding rules and the audit report now say every counted event, the push badge's included, passes HistoryVisibility::can_see, which is what excludes a backfilled pre-join event.
  • DOCS (PR #523 review): docs/todos/capability-gaps.md described counts.unread as the sum of /sync's per-room counts and said unread counts skip the history-visibility filter. src/sync/AGENTS.md listed the push badge among the counts made by count_unread_by_thread(). Each now matches ADR-0205 and ADR-0207.

0.12.39

Indexes the in-memory event store by event ID and by stream order (ADR-0215). Deferred from the review of PR #523 (conformance audit wave 5), whose capped push-badge count (ADR-0207) could not stop early or look thread parents up cheaply.

  • CHANGED (database::EventTable): PersistentStore::events is now an EventTable that owns the rows and keeps an event-ID → position map and a stream-order index of positions. The rows are read-only through it. Every change goes through insert/push_back, insert_all, erase_if, modify, extract or clear, so a writer cannot leave an index stale. modify cannot change a row's event_id or stream_ordering. An event ID is held at most once.
  • CHANGED (lookups): about fifty linear scans of every stored event by event ID, all under runtime.mutex, are now hash lookups (find, contains, find_in_room, with_id). They include sync::event_thread_id's up to three parent hops, history visibility, room read access, sliding sync's required state, heroes and room list, /event, /context, /relations, /redact, /report, read markers, notifications, the send_join auth chain (which also built a per-request map of every event in the room), auth-event maps, server ACLs, the stored room version, appservice delivery, space hierarchy, power levels, state bookkeeping, the federation event query, and the database's own status, redaction, state-match and duplicate checks.
  • CHANGED (stream order): events.in_stream_order() visits events by ascending stream_ordering whatever order they were hydrated (SQLite: no ORDER BY; PostgreSQL: event-ID order, and an updated row moves) or committed in (stream orderings commit out of order across rooms). Hydration and reload_room add their rows with insert_all, which sorts once instead of shifting the index per row.
  • TESTS: tests/unit/test_event_table.cpp (new) covers lookups and stream order after out-of-order insertion, duplicates, removal, modification, bulk insertion and copies, and on SQLite after out-of-order hydration, a status update, a redaction and a room reload. PostgreSQL scenarios in tests/integration/test_postgresql_persistence_flow.cpp cover hydration after a status update moves the row.

0.12.38

Conformance audit (2026-10-17) wave 4: the Client-Server API findings R10, R11, R12, R13 and R16.

  • FIXED (R10): GET and PUT /_matrix/client/v3/user/{userId}/rooms/{roomId}/account_data/{type} were not routed. They now store and read room account data, which /sync and sliding sync deliver in the room's account_data and which never falls back to the global type of the same name. A malformed room ID is 400 M_INVALID_PARAM. A PUT needs a membership row in the room (404 M_NOT_FOUND otherwise), and a new type past the new server.client_api.max_room_account_data_per_room (default 100) in that room is 400 M_TOO_LARGE; an update to a held type always succeeds (ADR-0197, refining ADR-0173).
  • FIXED (R11): both account-data PUTs stored any body. A body that is not JSON is now 400 M_NOT_JSON, JSON that is not an object 400 M_BAD_JSON, and the server-managed m.fully_read and m.push_rules 405 M_BAD_JSON, globally and per room. Nothing is stored.
  • FIXED (R12): POST /register without username was 400 M_BAD_JSON. The server now generates the localpart, as the spec requires: 16 hex characters from the CSPRNG, redrawn if reserved, taken or in an application service's namespace (ADR-0198).
  • FIXED (R13): typing never expired, and a typing that was not a boolean counted as true. PUT /rooms/{roomId}/typing/{userId} now refuses a missing typing (M_MISSING_PARAM), a non-boolean one or a non-integer timeout (M_BAD_JSON), a negative timeout (M_INVALID_PARAM) and a body that is not JSON (M_NOT_JSON), changing nothing. Typing lasts the client's timeout (30 s when omitted, at most 120 s) and, for a remote user, 60 s from their server's latest EDU. A new "typing-expiry" background job removes expired typing, wakes /sync and sliding-sync waiters with the room's new m.typing, and sends typing: false to remote servers for local users (ADR-0199).
  • FIXED (R16): /search covered only rooms the user was joined to. It now covers every room the user has been in, including rooms left, kicked or banned from after a join, each event still judged by history visibility, so nothing sent after the user left is returned. include_state gives a left room's state as of the leave. Forgotten rooms, invites and knocks are not searched.
  • FIXED (data race, found in review): the federated m.typing EDU sink, which the dispatcher runs without runtime.mutex, read and wrote typing state unlocked, racing the client typing route, /sync and the new typing-expiry job. It now takes the lock itself.
  • R12 follow-up: a username (or legacy user) that is present but not a string is now 400 M_BAD_JSON, not treated as omitted, so it no longer creates an account with a generated ID.
  • R13 follow-up: setting a user typing wakes the typing-expiry job, so a deadline shorter than the job's current sleep (up to 5 s) is no longer missed.
  • R20 (open): POST /register runs User-Interactive Authentication before the username checks the spec says must come first; recorded in the audit report, not yet scheduled.
  • FIXED (found in review): account-data and typing bodies were parsed with the canonical-JSON parser, which refuses fractional numbers, so a valid body such as {"order":0.5} was 400 M_NOT_JSON. They now use the general parser (account data is never signed), and a fractional typing timeout is 400 M_BAD_JSON. The sliding-sync account_data extension also read stored account data with the canonical parser and sent {} for an m.tag with an order; it now delivers the content. The same canonical re-read was fixed where stored account data reaches a client or is rewritten: the sliding-sync response assembly, room initialSync, sliding sync's is_dm (m.direct), and createRoom's is_direct update of m.direct, which started from an empty mapping and destroyed the user's existing direct rooms when their m.direct held a fractional number.
  • R13 follow-up: the typing-expiry pass no longer rescans the typing table for every expired room while holding the runtime lock; it groups the table by room in a fixed number of passes.
  • Tests: new tests/conformance/test_account_data_conformance.cpp and tests/conformance/test_typing_conformance.cpp; new scenarios for /register without username, /search of a left and a forgotten room, the room account-data caps, the room capped store, remote typing expiry and the new configuration key. The four conformance scenarios that asserted 404 M_UNRECOGNIZED for room account data are replaced by normative ones. The ignoring-users scenario for a malformed m.ignored_user_list now writes the bad row straight to the store, since the PUT API refuses it.

0.12.37

Conformance audit wave 3: the Application Service and Identity Service findings R07, R08 and R09, the appservice ping and network directories, and createRoom appservice delivery.

  • FIXED (R07): an appservice was interested in an event only through its sender, the room's joined members, or the room's first alias, so an invite to one of its virtual users (who is not yet joined) never reached it. Interest now includes the state_key of an m.room.member event (the target) and every alias of the room (appservice::EventInterestFacts). An invite received over federation for a room this server is not in, stored only as an outlier, is now queued too: no later transaction brings it again.
  • FIXED (R08): receive_ephemeral is honoured. m.typing, m.receipt (a private read receipt only for a user in the appservice's namespaces) and m.presence (including the one a profile change sends) are queued in memory per appservice, coalesced by what they describe, bounded (512 items, 100 per transaction, 20000 namespace matches per presence update), and sent in the transaction's ephemeral array, alone in an events-free transaction when no event is queued. A retry resends the same items under the same ID (ADR-0190).
  • FIXED (R09): the four requestToken endpoints forward send_attempt to the identity server.
  • ADDED: POST /_matrix/client/v1/appservice/{appserviceId}/ping and the outbound POST /_matrix/app/v1/ping, with duration_ms, M_URL_NOT_SET, M_FORBIDDEN, M_CONNECTION_FAILED, M_BAD_STATUS (status and a bounded body) and M_CONNECTION_TIMEOUT. The call releases runtime.mutex and holds a client-outbound slot; transaction_id is copied as given.
  • ADDED: PUT /_matrix/client/v3/directory/list/appservice/{networkId}/{roomId}, persisted in the new appservice_directory table (migration 025, schema version 25), and third_party_instance_id / include_all_networks on POST /publicRooms. instance_id is now <registration id>:<protocol>:<index> so it names the network (ADR-0192). An appservice may list only a room it is interested in, under a protocol it declares.
  • FIXED: createRoom queues the events it creates for interested appservices, as does the third-party invite stored on join.
  • SECURITY: the queue of one appservice is capped by appservice.max_queued_events (default 100000). Further events are refused, counted (merovingian_appservice_outbox_dropped_total), logged and audited as appservice.outbox.full (ADR-0191, noted on ADR-0163). New metrics: merovingian_appservice_outbox_queued and merovingian_appservice_ephemeral_dropped_total.
  • SECURITY: every client-server route that forwards an id_access_token refuses one that is not printable ASCII without spaces of at most 4096 bytes (400 M_INVALID_PARAM), before the outbound client's own header check (ADR-0180).

0.12.36

Fixes the federation findings of the 17 October 2026 conformance audit (wave 2: R04, R05, R06, R18). No migration.

  • FIXED (R04): this server could not be the resident that authorises a restricted-room join. GET /make_join never named a join_authorised_via_users_server, and send_join never added this server's signature, so every non-invited join into a restricted or knock_restricted room failed. homeserver::decide_restricted_join now checks the room's allow conditions against this server's current state and picks a joined local user with invite power. make_join names that user (and their member event among the auth events) or refuses with 400 M_UNABLE_TO_AUTHORISE_JOIN, 400 M_UNABLE_TO_GRANT_JOIN or 403 M_FORBIDDEN. An allow condition counts as verifiable only while this server is resident in the room it names: state kept after leaving is not current. send_join co-signs only a join it authorises, before it is stored, after dropping any signature the joining server filed under this server's name; it returns the co-signed event. A local user's join into a local restricted room carries the field too, and a client cannot name an authoriser through PUT /state to skip the check. A join by a user who is already joined (a profile update that copies the join's content) has the field dropped instead, as m.room.member encourages, so it is not refused. Federation workers relay make_join to main, which holds every room the conditions name (ADR-0184, ADR-0185).
  • FIXED (R05): the sender's signature on a relayed PDU, an invite, a worker-relayed event, a backfilled event and send_join state was checked only against the first key ID listed for the sender's server. Every listed ed25519 key ID is now checked, as "Validating hashes and signatures on received events" requires: an unknown key, or one expired before the event's origin_server_ts, is skipped; a known, unexpired key whose signature does not verify refuses the event; at least one must verify. An event listing more than four key IDs for its sender's server is refused before any lookup, and a local key-lookup overload is a retryable 503 (ADR-0183).
  • FIXED (R06): send_join, send_leave, send_knock and /invite (v1, v2) refused an event whose content hash did not match. The spec says such an event "is redacted before being processed further": the acceptor and the invite handler now redact it and authorise, sign, store and return the redacted copy. An invite for a room this server does not hold is redacted and signed under the request's room_version, not version 10. A signature failure is refused with 400 M_INVALID_PARAM, as those endpoints say.
  • FIXED (R18): GET /query/profile refused field=m.tz (and any other key) with 400. Any field is a valid request; one the user does not have is 404 M_NOT_FOUND, and an unset field is left out of a full profile instead of being sent as "".
  • FIXED: the make_join template provider read room state without the runtime lock.
  • DOCS: ADR-0183 to ADR-0185; the conformance audit report marks R04, R05, R06 and R18 fixed; docs/event-engine.md, docs/threat-model.md, docs/security-coding-rules.md, docs/architecture.md, docs/todos/capability-gaps.md and the federation, homeserver, federation-worker and IPC module rules updated.

0.12.35

  • FIXED: PUT /_matrix/client/v3/profile/{userId}/displayname (and avatar_url) answered 500 after storing the new value whenever one joined room or the presence write could not take the propagated update, so Element reported a failed change that had happened. A room that refuses every time (an unsupported room version, a missing create event, a non-federating room created elsewhere) made every later profile change fail as well. The PUT now answers 200 once the field is stored, as the endpoint's responses require; a room that fails is logged at warning (profile.propagation.room_failed) and is sent the update at the next profile change (ADR-0182).
  • FIXED: profile propagation chose rooms from the runtime member list, which can name a user the room's state says has left. In a public room the join it sent then rejoined them. Rooms are now chosen by the user's current m.room.member state; a room only the list names is skipped (profile.propagation.not_member).

0.12.34

Fixes the six LOW findings from security audit run-1 (2026-10-20), and two defects found while fixing them.

  • SECURITY (sp02.is-token-crlf-header-injection): a client's id_access_token reached the outbound Authorization header unfiltered, and the pinned libcurl writes header lines verbatim, so CR/LF in it added header lines. The outbound client now refuses, before any network I/O, a header name that is not an RFC 9110 token or a value with a control character other than HTAB (OutboundError::invalid_header, ADR-0180).
  • FIXED (fed.outbound-fanout-rfind-colon-port-server-name): a member's server name was taken after the last : of their user ID, so a member on host:8448 was addressed as 8448 and never received the room's events. Every user ID, room ID and alias split in src/homeserver/ now uses the first : (auth::identifier_server_name, auth::user_id_server_name): room-event and EDU fan-out, device-list destinations, key query/claim routing, invite targets and alias lookups.
  • SECURITY (fed.outbound-fanout-unbounded-destinations-queue-poison): a destination whose circuit stayed open kept its transactions queued without counting retries, so one dead or hostile peer could fill the shared outbound queue and every other peer's transactions were refused. Each destination now holds at most its share of the queue (max(16, depth / 16), 256 by default), counting its queued and in-flight transactions, and replay after a restart parks rows past the share instead of promoting them. Refusals are counted (merovingian_federation_outbound_rejected_total) and the room-event fan-out logs them at warning level (room.event.dispatch_refused) instead of discarding them (ADR-0177).
  • FIXED (durable outbound queue): both fan-outs gave every destination one transaction ID, which is the durable federation_transactions key. A fan-out kept one durable row, and the first delivery deleted it, so a restart could not replay the other destinations. Each destination now gets its own transaction ID.
  • SECURITY (med01.remote-media-owner-sentinel-localpart-collision): remote media was told apart from local media by its owner starting with @remote-media:, which a local account named remote-media imitated to escape max_total_size and max_records. Each media record now carries from_remote_fetch, rebuilt on startup from the owner and the local server name, and the localpart remote-media cannot be registered (400 M_USER_IN_USE, ADR-0178).
  • SECURITY (rooms.room-version-resolution-divergent-fallbacks-create-without-room_version): components derived a room's stored version with different defaults ("1", "10", "12"), and a federated join never checked the create event against the make_join version. There is now one accessor (homeserver::stored_room_version): a create event without room_version, or with an unusable one, is version 1 and has no policy; the accessor never returns an empty version, which several parsers would replace with "12". A join is refused (502) unless the create event declares the make_join version. Auth rule 1.3 also rejects a room_version that is not a string (ADR-0179).
  • SECURITY (sp02.thirdparty-no-proxy-slot): the /thirdparty/* routes called appservices without a client outbound proxy slot. Each now takes one before calling, and answers 429 M_LIMIT_EXCEEDED when none is free (ADR-0181).
  • OBSERVABILITY: new metrics merovingian_federation_outbound_rejected_total and merovingian_federation_outbound_pending, and log event room.event.dispatch_refused.
  • TESTS: new scenarios for each finding, a conformance scenario for first-colon splitting of every sigilled identifier and for rule 1.3 with a non-string room_version, and tests/unit/test_stored_room_version.cpp.
  • DOCS: ADR-0177 to ADR-0181; docs/threat-model.md, docs/security-coding-rules.md, docs/observability-audit.md, docs/http-transport.md, docs/media-repository.md and the src/http, src/federation, src/homeserver, src/media and src/auth AGENTS.md files.

0.12.33

Fixes the five MEDIUM findings from security audit run-1 (2026-10-20) where a request could make the server do work, sized by its own input, while the server-wide runtime lock was held.

  • SECURITY (sliding-sync extension rooms, sp01.sliding-sync-extension-rooms-unbounded): the receipts and typing extensions' rooms arrays were uncapped and not de-duplicated. Each named room cost a scan of every membership row (and, for receipts, every stored receipt). Each array is now capped at the room_subscriptions limit (default 256, 400 M_INVALID_PARAM beyond), repeated room IDs are kept once, and the rooms are classified through sync::room_access_index, one pass over the caller's memberships built once per request. Receipts and typing then make one pass over their tables. The handler builds one index for room_subscriptions and passes it to build_extensions, so a request scans the memberships once (ADR-0174).
  • SECURITY (push glob matching, sp01.push-glob-dp-allocation-per-character): event_match allocated a full (value × pattern) table per match, and the content.body matcher tried every pair of word boundaries. Whole-value matching now compares exactly when the pattern has no wildcard, and otherwise uses a greedy matcher that backtracks to the last * with no allocation. Word-boundary substring matching is one pass over the body. The server evaluates only the default ruleset today (user push rules are not writable), so the body matcher was latent; it is fixed anyway (ADR-0175).
  • SECURITY (account data, sp02.account-data-unbounded-linear-scan): a user could add global account-data types without limit, growing the rows every account-data read scans under the lock. New server.client_api.max_global_account_data_per_user (default 1000, 1 to 100000, restart required). A new type past it gets 400 M_TOO_LARGE; updating a type the user already holds always succeeds. The check and the write share one pass over the rows (database::store_global_account_data_within_cap). Room account data is bounded by membership and is not counted (ADR-0173).
  • SECURITY (appservice namespaces, sp02.appservice-regex-compile-under-mutex): each namespace regex was compiled on every match, once per member per event, under the lock. appservice::Namespace now compiles its pattern once, when it is built, and shares the compiled form between copies. The pattern is private (regex()), so it cannot drift from the compiled form. A malformed pattern still matches nothing.
  • SECURITY (federation media multipart, med01.federation-multipart-quadratic-boundary-scan): the delimiter search compared the boundary at every offset of a body a remote server controls, which a long, repetitive boundary made O(body × boundary) under the lock. A boundary longer than RFC 2046's 70 characters is refused, and delimiters are found with a linear Knuth-Morris-Pratt scan (ADR-0176).
  • TESTS: new [security][sp01], [security][sp02] and [security][med01] scenarios cover each fix: the push matchers are checked against the old implementation on 3000 seeded cases, room_access_index against room_access_for, and the multipart parser against a pathological all-dash boundary and 1 MB of dashes. The receipts and typing grouping is checked across interleaved rooms. New tests/unit/test_account_data_cap.cpp.
  • DOCS: ADR-0173 to ADR-0176; docs/threat-model.md, docs/security-coding-rules.md, docs/user-manual.md, docs/media-repository.md, and the src/sync, src/push, src/appservice and src/homeserver AGENTS.md files.

0.12.32

  • FIXED (federated restricted joins were always refused): a join into a restricted or knock_restricted room by a user who is not invited carries join_authorised_via_users_server. The auth rules then need that user's m.room.member event to check they are joined with invite power (rule 4.3.5.2). "Auth events selection" names that event for such a join, and state resolution selected it, but the router's ingestion auth maps (build_auth_event_map_from_entries) never did. So /send, send_join and backfill refused every such join, genuine or not. All eight map builds on those paths now select it. This fix depends on 0.12.31, which made every ingestion path verify the authorising server's signature first (ADR-0171).
  • TESTS: the worker-relay and backfill [evt1] scenarios now use a restricted room instead of a public one. A genuine authorised join is accepted, forged authorising signatures are still refused, and a validly signed join naming a user who is not in the room is refused.
  • DOCS: docs/todos/capability-gaps.md marks the 0.12.31 receiving-side gap resolved. docs/event-engine.md describes the auth-map selection.

0.12.31

Fixes the two HIGH findings from security audit run-1 (2026-10-20).

  • SECURITY (forged restricted joins via worker relay or backfill): an m.room.member event carrying join_authorised_via_users_server must be validly signed by that user's server (rooms/v10.md rule 4.2.1). Only /send and send_join verified that signature. Main's re-verification of PDUs relayed by the federation worker, and backfill / missing-history ingestion, checked only the sender's signature, so the structural auth rule accepted any non-empty authorising-server signature. In a restricted room this was latent: those paths' auth maps never carry the authorising user's membership, so they refuse every non-invited restricted join anyway (a separate bug, not fixed here). In a room with any other join rule, a join carrying a forged authorising signature was accepted, which rule 4.2.1 forbids. All four paths, and the send_join state this server stores when it joins a remote room, now call one helper, federation::verify_join_authorising_server_signature. It resolves the authorising server's key through the caller's admitted lookup and refuses the event unless the signature verifies. A local key-resolution overload is retryable (503 / main_overloaded), never a verdict. Any listed authorising-server key may verify the event (up to four are tried), because a relaying server can add key IDs to the unsigned signatures object; backfill stops starting candidate lookups at its deadline. A join_authorised_via_users_server that is not a user ID names no homeserver, so the join is refused rather than treated as if the key were absent. The helper applies the rule to m.room.member events only, as the spec says; the previous /send code also applied it to other event types (ADR-0171).
  • SECURITY (one sliding-sync request could stall the server): by_recency and by_name were computed inside the sort comparator, rescanning the event store on every one of O(N log N) comparisons while the runtime lock was held. Every sort key is now computed once per room before the sort: by_recency in one newest-first pass that skips events that cannot beat the room's current maximum, and by_name in one pass over state and one over events. Requests over 32 lists, 3 sort keys per list or 64 bump_event_types per list are refused with 400 M_INVALID_PARAM (ADR-0172).
  • TESTS: branch coverage for verify_join_authorising_server_signature; forged and valid authorising signatures on the worker-relay (pdu_ingest, membership_ingest) and backfill paths, and in the send_join state this server keeps when it joins (forged, absent, genuine); the sliding-sync ordering contract over ascending, descending and shuffled stores; and the new request caps at and over each limit.
  • DEPENDENCIES (docs toolchain): certifi 2026.7.22 → 2026.10.19 in requirements-docs.txt for GHSA-6q48-6c7w-9gw2 (critical, published 2026-10-20). Releases before 2026.10.18 bundle emSign root CAs whose intermediate CA keys were compromised; the advisory's version range names 2026.10.19 as the first patched release, which is what scanners enforce, so that is the floor. certifi reaches only the MkDocs site build (through requests); the server does not use it. requirements-docs.in floors it at 2026.10.19, and tests/tooling/test_docs_site.py refuses a lock below that. The advisory also broke the dependency-triage job: Grype reported the docs-lock package with an empty location, and Code Scanning rejected the SARIF upload.
  • DOCS: ADR-0171 and ADR-0172; rules added to src/federation/AGENTS.md, src/homeserver/AGENTS.md, src/sync/AGENTS.md and docs/security-coding-rules.md; threat-model entry; docs/event-engine.md; a correction to the EVT-1 residual note in the 2026-09-29 audit report, which said every PDU path verified the signature.

0.12.30

  • FIXED (resident-supplied profile signed as ours): a federated join kept a displayname or avatar_url from the resident server's make_join template whenever the joining user had not set that field locally, and this server then hashed and signed it as the user's profile. The template's profile fields are now dropped before the local profile is added. The live federated join scenario covers no profile, an empty profile, and display-name-only and avatar-only profiles against a template carrying forged values.
  • TESTS: give the thread-root thread_id receipt conformance scenario the required // Endpoint / Section: citation.
  • TESTS: the self-signed certificates in the join, outbound-federation and listener flow tests are backdated an hour. They were valid from the instant of creation, so a backward clock step under WSL made one "not yet valid" mid-scenario (tls_verification_failed).
  • DOCS: include receipts in the worker table inventory and correct the worker-never-read total to 46 tables, matching the schema classification test.
  • CI / REVIEW: use an explicit string view for the notification-sort lookup so GCC 16 can compile it; align the profile propagation definition's [[nodiscard]] annotation and the threaded-receipt scenario's spec citation.
  • FIXED: commit inbound receipt eviction and replacement in one transaction. A failed delete or insert preserves the old receipt in memory and on disk. Add SQLite failure-injection and restart checks; correct the push unread-count documentation to describe thread-partitioned read markers.
  • MERGE: integrate the 0.12.29 documentation and startup-example corrections, retaining the 0.12.30 versions and thread-aware receipt documentation.
  • REVIEW FIXES: serialize inbound receipt callbacks with runtime readers; cache notification counts once per room before sorting sliding-sync lists; reject a thread reply supplied as a receipt's thread root; and report incomplete room or presence propagation with HTTP 500. Repeating the same profile update repairs missing updates without duplicating membership events already current. Add failure/retry, receipt-locking, thread-root, and notification-ordering regressions, and clarify the conformance assertions' specification rationale.

Two user-reported defects: a changed display name was never seen in any room, and rooms marked read came back unread.

  • FIXED (display name and avatar never reached any room): PUT /profile/{userId}/displayname and /avatar_url updated only the profile row. Clients show members by the displayname and avatar_url of their m.room.member events, so neither the user nor anyone else saw the change. Spec, "Events on Change of Profile Information": "an m.room.member event (with a join membership) is sent to every room the user is a member of". homeserver::propagate_profile_change now sends that join, carrying the current display name and avatar URL, through send_event to every joined room whose membership does not already carry it. It is federated, pushed and delivered to appservices like any other event. A room that refuses the event is skipped and logged. The section's second mechanism, "an m.presence presence status update ... containing the new values of the displayname and avatar_url keys, in addition to the required presence key", is sent too: the user's current presence (offline if never set) is stored again at a new sync stream position, and every m.presence in /sync now carries the sender's display name and avatar URL when this server holds their profile. Presence is not federated by this server, so remote peers learn the change from the member events. A failed profile write now answers 500 instead of 200, as does incomplete room or presence propagation. The profile itself may already be saved; retrying the identical request repairs pending room updates while skipping rooms already current. The spec also says that membership events a server emits for its own users "should include the display name and avatar URL fields": local joins, the creator's join in createRoom and the join sent over federation after make_join now carry them. Scenarios tagged [profile-propagation].
  • FIXED (rooms marked read came back unread): read receipts lived only in HomeserverRuntime::receipts, in memory. Every unread count is computed from them, so a server restart reset every room to unread. They are now kept in a receipts table (migration 024, schema version 24), mirrored in PersistentStore::receipts and written through database::store_receipt / remove_receipt. HomeserverRuntime::receipts and InboundReceipt are gone. The inbound federation cap no longer evicts a local user's receipt, only the oldest receipt of a remote user (ADR-0169). Scenarios tagged [receipt-persistence].
  • FIXED (threaded receipts): thread_id was ignored. A receipt in a thread replaced the user's main-timeline receipt of the same type, which could move the read position back and mark the room unread again. Receipts are now keyed by (room, type, user, thread_id). Spec: "Each user_id, receipt_type, and categorisation (unthreaded, or thread_id) tuple must be associated with only a single event_id". POST /receipt answers 400 M_INVALID_PARAM for a thread_id that is not a string, is empty, is sent with m.fully_read, or is neither main nor the ID of an event in the room (so a client cannot create a receipt row per invented thread). An inbound federated thread_id over 255 bytes rejects the EDU. m.receipt in /sync and the sliding sync receipts extension carry thread_id (one shared sync::ReceiptContentBuilder). Federated receipts carry it both ways in data.thread_id.
  • FIXED (unread counts ignored threads): counts are partitioned by thread as the spec requires. An unthreaded receipt covers every earlier event. A threaded receipt covers only its own thread (main or a root's event ID), resolved through up to 3 relation hops (sync::ReadMarkers, sync::read_markers_for, sync::event_thread_id). sync::read_receipt_ordering is replaced by sync::read_receipt_markers, and count_notifications / count_highlights take ReadMarkers. This applies to /sync, sliding sync, the room-list by_notification_count sort, the push-gateway badge and GET /notifications read. Scenarios tagged [threaded-receipts] and [read-markers].
  • NEW (per-thread unread counts): /sync honours the RoomEventFilter flag unread_thread_notifications (read from room.timeline). With it set, unread_notifications counts only the main timeline and unread_thread_notifications holds the counts of each thread that has any, keyed by root event ID; without it the counts are summed as before. sync::count_unread_by_thread is the one count walk; count_notifications and count_highlights sum it. A highlight is now counted once per event rather than once per matching m.mentions entry. Scenarios tagged [thread-notifications].
  • FIXED (sending a message did not mark earlier messages read): spec, "When the user updates their read receipt (either by using the API or by sending an event), notifications prior to and including that event MUST be marked as read". A non-state event the user sends now reads its own thread up to itself (sync::read_markers_for). State events do not count, so the membership update a profile change sends to every room marks nothing read. The position is not stored or sent as a receipt (ADR-0170). Scenarios tagged [own-event-read].

0.12.29

  • DOCS: reconcile security guarantees, master-key lock failure policy and its superseded ADR, implemented endpoint status, runtime lock scopes and sync capacity with the current implementation. No runtime security policy changes.
  • DOCS: correct application-service YAML registration support, autonomous delivery retries, PostgreSQL migration-role enforcement, push budgets and rate-limit defaults (including the stable sliding-sync generic-tier exception). Document the SQLite worker's broader hydration and remaining signing-buffer lock limits. Include listeners.max_queued_connections in the configuration example.
  • DOCS: add backend-specific backup and restore procedures with validation steps, and align version-bump timing with the branch-creation rule.
  • DOCS (review fixes): the README master-key step now uses mode 0400 and owner merovingian, as the startup secret-file check requires (0600 was rejected). The restore procedure names the server.name key, not server.server_name. The manual notes that the Docker image needs a master key mounted at /etc/merovingian/master-key.
  • TESTS: the example-config smoke tests (server-check-config, server-example-config, server-example-config-summary) now provision an owner-only master key and point the copied example at it. The example enables security.secrets.master_key_file, and a configured key that is missing fails --check-config and --dry-run, which failed every CI build job. scripts/validate-phase1-config.sh, which the Linux CI jobs run after the test suite, does the same for its --dry-run of the example (it exited 66, config_io_error).

0.12.28

First wave of fixes for the 17 October 2026 Matrix v1.19 conformance audit (docs/conformance-audit-report-2026-10-17.md): the three findings that lost delivery work while reporting success (R01–R03), plus two to-device defects found while fixing R02.

  • NEW (PeriodicJob): homeserver::PeriodicJob runs one job on a dedicated thread, once at start, again on wake(), and otherwise after the delay its last run returned; the job gets a homeserver::StopFlag it polls between units of work. One thread per job, so a queue drained by a job has one consumer. The thread starts only in start() (ADR-0082). It is built on std::thread, not std::jthread/std::stop_token, which the libc++ of FreeBSD and OpenBSD lacks; docs/coding-rules.md now forbids both (ADR-0167). Scenarios tagged [periodic_job].
  • FIXED (R01, appservice events skipped after an outage): the appservice cursor held one pending event; when its retry failed the triggering event was not queued, and when the appservice recovered every event in between was skipped. Nothing retried once room traffic stopped, and delivery ran on the request thread with the runtime lock released, so two requests could race on one cursor. A request now only queues: enqueue_appservice_delivery writes an appservice_outbox row (migration 022, interest decided at event time) and wakes the one sender, drain_appservice_queues, run by a PeriodicJob from main.cpp. A transaction's ID and event are written to the cursor before it is sent; on acknowledgement the cursor and the outbox row commit together. Failures back off from 1 s to 5 min per appservice. The outbox primary key is the only dedupe: there is no "already delivered" threshold, because events in different rooms commit out of stream order. deliver_federation_push_notifications now takes the runtime lock (its caller did not hold it). ADR-0163; scenarios tagged [r01].
  • FIXED (R02, a failed to-device enqueue answered 200): PUT /sendToDevice discarded the enqueue result, recorded the transaction and returned 200, so the client's retry was replayed without delivery. Remote EDUs are now queued first and a queueing failure answers 500; then every local message and the transaction record commit in one backend transaction (enqueue_to_device_messages_with_client_txn), and a failed commit answers 500 with nothing recorded, so a retry with the same transaction ID is delivered. With security.federation.enabled = false a remote recipient is discarded like an unknown local one rather than failing the request for ever. ADR-0164; scenarios tagged [r02].
  • FIXED (R03, federation PDUs lost after a 200): a PDU whose prev_events/auth_events could not be backfilled, or whose prev_event had no state group, was dropped while /send answered 200 with no error for it, and the sender never resent it. It is now retained in deferred_pdus (migration 023; at most 100 per room, 1000 in total, 16 MiB, 24 h, 10 retries) with the new status PduIngestionStatus::deferred, which /send reports as handled; a PDU that cannot be retained gets an error in the pdus map. DeferredPduRetryJob retries due PDUs through the federation PDU sink with back-off from 30 s to 1 h, and the arrival of an event a retained PDU names makes it due at once. PDUs held until an outbound join commits are also reported as deferred. New audit event federation.pdu_deferral_expired. ADR-0165; scenarios tagged [r03].
  • FIXED (to-device message IDs collided across senders): an outbound m.direct_to_device EDU used the client transaction ID as its message_id, which is unique only per user, so two local users with the same transaction ID had the second message dropped as a replay by the receiver (and an ID over 32 codepoints was invalid). The ID is now derived from the sender, event type and transaction ID (derive_direct_to_device_message_id): stable across a client retry, distinct across senders, 24 characters. Inbound, the replay window is keyed on (origin, sender, message_id), the EDU is stored in one backend transaction (enqueue_to_device_messages) and its ID recorded only after the commit, so a failed store no longer turns the origin's resend into a dropped replay. ADR-0166; scenarios tagged [message-id].
  • FIXED (line-endings-tooling timeout): the test ran git ls-files --eol before each of its four cases, about 6.5 s each on a WSL /mnt/c checkout, so under a parallel meson test it exceeded Meson's 30 s default timeout. It now scans once per class (setUpClass).
  • DOCS: the audit report and its remediation status are recorded in docs/conformance-audit-report-2026-10-17.md.

0.12.27

Fixes the intermittent createRoom 500 behind the flaky sync conformance scenario, makes Unix-consumed files check out with LF line endings on every platform, and fixes the two failures behind the OpenBSD CI job's red runs (7 of the last 9 before this release): a retried federation transaction processed twice, and an integration test hung in connect(). The federation worker's room reloads now also read one consistent snapshot.

  • FIXED (createRoom 500 on a room ID collision): in room version 12 the room ID is the create event's reference hash, and a v12 create event has no room_id and no prev_events, so two createRoom requests from one user with the same creation content in the same millisecond composed byte-identical create events, derived the same room ID, and the second failed on the duplicate room row with a 500 logged only at debug. This is what made the conformance scenario "GET /sync returns spec-shaped room category objects" fail intermittently (5 of 40 runs in a release build). create_room now stamps each create event strictly after the previous one this runtime composed (HomeserverRuntime::last_create_event_ts_ms), reserved before the policy-server hook releases the runtime lock, and steps past any timestamp whose room ID already exists, as after a restart with the clock stepped back (ADR-0156). Every 5xx from create_room (createRoom, /upgrade, the local router) is now logged once at error as room.create.failed with its reason. New test seam HomeserverRuntime::test_frozen_event_clock_ms freezes the event clock; the new [create-room-collision] conformance and integration scenarios use it to reproduce the collision on every run. The runtime's move constructor and assignment now carry both new fields. A further integration scenario, "a room creation that fails on the server leaves no room behind" ([create-room-failure]), starts a runtime that cannot sign and checks the 500 leaves no room row, member or timestamp reservation. The size-limit conformance scenario now also sends creation_content that passes the request pre-check but makes the signed v12 create event exceed 65536 bytes, and gets 400 M_TOO_LARGE with no room created.
  • FIXED (CRLF working trees): a checkout made by Windows Git with core.autocrlf=true wrote CRLF into every text file, because .gitattributes covered only *.sh, *.ps1 and *.cmd. A full WSL run from such a checkout failed the [platform][iso8] systemd unit scenario (it matches "\nRestrictAddressFamilies=…\n"), and a package built from it would have shipped CRLF unit files and maintainer scripts. .gitattributes now sets * text=auto eol=lf for the whole tree; Windows-only .ps1 and .cmd keep CRLF. The index was already LF, so no stored file changes (ADR-0158). New tooling test tests/tooling/test_line_endings.py (line-endings-tooling): every tracked text file carries eol=lf, the Windows scripts eol=crlf, and nothing is stored with CRLF; RED with 2 failures before the change. docs/dev-environment.md gives the one-off re-checkout for existing working trees.
  • FIXED (OpenBSD CI, federation transactions): a retried PUT /send/{txnId} was processed a second time when its room moved between the federation worker and main. Matrix v1.19 has the sender retry one txnId until it receives 200, so a completed transaction must not run again; its PDUs were caught by event-ID dedupe, but its EDUs (typing, receipts, to-device messages) were delivered twice. Each process kept its own dedupe ring, and the proxy sends a room's transactions to main while that room's worker reload has failed, so a transaction the worker completed was new to main, and one main completed during a fallback was new to the worker once the room recovered. Main's ring is now the record: the proxy records each /send the worker answers with 200 there, and hands any /send already in it to main's handler, which answers it as a duplicate after its usual rate limit (ADR-0160). New public helpers federation_send_transaction_id, federation_transaction_completed and record_completed_federation_transaction (federation/inbound_request.hpp); main's own handler appends through the last one. This was the failure in "main key-admission overload survives real worker socket HTTP and unchanged transaction retry" (3/3 on PR #510, cache_misses 3 == 2, raw_calls 2 == 1, edu_deliveries 2 == 1). Tests: [txn-dedupe] in test_federation_worker_flow.cpp forces the room's reload to fail by moving the SQLite file aside, then retries the same signed bytes both ways round (worker first, then main; main first, then the recovered worker). RED on Linux: edu_deliveries 2 == 1, the CI failure reproduced off OpenBSD.
  • FIXED (test, postgres-integration): "PostgreSQL migrations execute as the migration role, never as the login role" failed whenever it was the first scenario to open a fresh database (Catch2 seed 1273323633 on PR #510: unable to migrate PostgreSQL schema, then unable to assume PostgreSQL migration role). Its first two cases assume the schema is already current, but nothing made it so. The login role bootstrapped v1 and owned its tables, and the pending migrations then ran as merovingian_migration, which cannot ALTER them. The scenario's setup now brings the schema current as its owner and hands ownership to the migration role, as provision-roles.sql leaves a deployment, and both cases print the open's refusal reason. Reproduced locally by replaying the seed's first 30 integration scenarios against a fresh PostgreSQL 16 cluster with CI's roles; passes after.
  • FIXED (OpenBSD, unveil): a failed unveil(2) left the process half-sandboxed. The OpenBSD runtime profile called unveil path by path and stopped at the first failure, but the first call had already hidden the rest of the filesystem, and no call can be undone. OpenBSD has no /var/lib, so unveiling /var/lib/merovingian failed with ENOENT after /usr and /etc/merovingian were unveiled. The federation worker applies this profile after loading its store when federation.worker.apply_hardening=false, so every later room reload failed at open (room_snapshot.read_failed stage=open, 6 of 6 on PR #510's OpenBSD run). This, not the SQLite race below, is why the room fell back to main on OpenBSD. openbsd_unveil_rules() now plans every call first, and none is made unless every path, or the directory it would be created in, exists (ADR-0162). Main still refuses to start when the control fails. Tests: [unveil] in test_runtime_hardening.cpp checks the planned calls and that a missing /var/lib plans none. The OpenBSD-only apply code was syntax-checked off OpenBSD with stub unveil/pledge declarations; it has not run on OpenBSD.
  • FIXED (SQLite, read-only connections): a read-only read could fail when one of main's commits landed between SQLite's two hot-journal checks. SQLite checks that the journal exists and then that no process holds RESERVED; a writer that deletes its journal and unlocks in between makes its own commit look like a crash (ticket #3883). A read-write connection settles that under EXCLUSIVE; a read-only one fails at once with SQLITE_READONLY_ROLLBACK, skipping the busy handler. The open-time pragmas, prepare and every read's first step now retry that one result every 10 ms for up to 2 s (ADR-0159). A journal that is really hot still fails the read and is left for main's read-write connection to roll back. This covers the room reload, the worker's startup load, and media blob and audit reads. A failed room snapshot logs room_snapshot.read_failed with its stage and extended SQLite code, and a connection whose pragmas fail logs connection.rejected with its code. That log line is what showed the OpenBSD reloads failing at open, which turned out to be the unveil bug above rather than this race. Tests: [sqlite-false-hot-journal] in test_sqlite_security_policy.cpp puts a journal that looks hot beside the database and removes it 200 ms later, as the committing writer does; the room snapshot and the worker's store load now succeed, and a journal that stays still fails within the budget and is left untouched. RED 2 failures.
  • FIXED (OpenBSD CI, test harness): "Federation socket HTTP refuses NUL path components without disconnecting or changing room data" and its proxy twin hung until meson killed the integration binary at 1800 s (main at b200c912; PR #510 run 38965086645). The reported "fatal error condition" was that SIGTERM, not a crash. The last passed assertion was the setsockopt just before a blocking connect() to the test's own listener (backlog 128, 6 to 9 connections in), so the test thread slept in connect() for about 29 minutes, far past TCP's 75 s connection-establishment timer. Production code makes no raw connect() call. Every integration-test loopback connect (12 call sites in 10 files) now goes through merovingian::tests::connect_within (tests/integration/bounded_connect.hpp): a non-blocking connect polled in 100 ms slices against a 5 s deadline, which restores the descriptor's blocking mode. A missed wakeup now costs one slice, and a connect that cannot complete fails one scenario. Not reproduced off OpenBSD. Tests: [bounded-connect] in the new test_bounded_connect_flow.cpp. docs/testing-standards.md gains "Socket calls in integration tests".
  • FIXED (worker room snapshot): the federation worker's room reload could serve a room in a state main never committed. load_room_snapshot_impl read rooms, membership, invites, events, current_state, event_edges, event_auth and event_signatures as eight separate statements, each its own read (SQLite autocommit; PostgreSQL READ COMMITTED). A commit by main between two of them mixed rows from before and after it: for example, current state naming an event the snapshot did not hold. The worker answers /state, /state_ids, make_join and /event from this snapshot. Each room reload now reads in one transaction: a deferred BEGIN on SQLite (SqliteReadTransaction, whose first read holds SHARED to the end, so main's commit waits) and BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY on PostgreSQL (ADR-0161). ADR-0159's retry of a journal that only looks hot still runs on the first read inside the transaction. The worker's whole-store startup load stays per statement, so on a large store it never holds SHARED for longer than main's 5 s busy timeout. detail::load_room_snapshot_from_sqlite and detail::load_room_snapshot_from_postgresql gain an optional RoomSnapshotReadHook, a test seam called after each table's read; production passes none. Tests: [room-snapshot-consistency] in test_sqlite_security_policy.cpp and test_security_audit_postgresql_reload_flow.cpp commit a new event and make it current state between the events and current_state reads, then check the snapshot's state names only events it holds. RED on both backends (PostgreSQL run against a local server through MEROVINGIAN_TEST_POSTGRESQL_URI).

0.12.26

Completes the 29 September 2026 security audit: every remaining low-severity finding is fixed, so all 101 findings (1 critical, 23 high, 31 medium, 46 low) are now closed. Entries marked PARTIAL or IN PROGRESS further down record intermediate steps on this branch; the FIXED and ACCEPTED entries at the top supersede them. Full WSL suite at the final commit: 57 OK, 0 failed, 0 timed out.

  • FIXED (PR #509 CI, SQLite locks): opening a SQLite connection released another connection's write lock. Closing any descriptor of a file drops every POSIX lock the process holds on it, and open_sqlite_connection opened and closed the database file itself on every connection (the DB-10 owner/type/mode check on the read-write path, the ISO-4 WAL check on the read-only one). Another process then found a journal with no RESERVED lock and took it for a hot one: on OpenBSD the federation worker's read-only room reload failed, the proxy fell back to main, and a retried transaction was processed twice ("main key-admission overload survives real worker socket HTTP and unchanged transaction retry"); a read-write process could have rolled back a transaction still in progress. An existing file is now checked by name (fstatat, AT_SYMLINK_NOFOLLOW) and opened only to tighten a loose mode, and the read-only WAL check reads the header through SQLite's own descriptor (SQLITE_FCNTL_FILE_POINTER) before any statement runs (ADR-0157). Tests: [sqlite-locks] in test_sqlite_security_policy.cpp (a forked process checks the RESERVED lock survives a read-write and a read-only open; a WAL database is still refused read-only without creating -shm); RED 2 failures. The same CI round fixed two build breaks: gcc's -Wnull-dereference at -O2 (Fedora, RHEL RPM builds) on GatewayCircuitBreaker::record's eviction, now guarded like ThumbnailCache's, and the startup hardening gate's GNU-only \| basic-regex alternation, which OpenBSD's grep reads literally (grep -E; tests in test_startup_hardening_gate.py). The new test declares its lock probe as struct flock, since the BSDs also declare a flock() function and flock{} did not compile there.
  • FIXED (PR #509 review, CI): the branch did not build outside WSL/gcc. A Catch2 CHECK on a std::tuple comparison failed to compile under clang with libstdc++ 14 (test_worker_relay_signature_flow.cpp now compares a struct); postgresql_store.cpp lacked <netinet/in.h>/<sys/socket.h> on the BSDs; a Linux-only test helper left a parameter unused elsewhere; and observability called database::bounded_utf8, a dependency against the module order (database depends on observability) that broke the fuzz link. bounded_utf8 and max_audit_field_bytes now live in observability/bounded_text.hpp, and the logger fixture's workaround is gone. The docs build failed on a broken anchor in ADR-0132. The new package gates exposed two packaging bugs: packaging/deb/conffiles listed the directory /etc/merovingian, so dpkg unpacked it as /etc/merovingian.dpkg-new and the file inside could not be created, and the Dockerfile used addgroup/adduser, which debian:trixie-slim does not ship (now groupadd/useradd). The .deb also stopped shipping the vendored sqlite3 archive and headers (--skip-subprojects, as every other package script). Tests: test_packages_workflow.py (conffiles name files the package installs, the image creates its user with tools its base ships, no subproject install); RED 3 failures. The first CI run on these fixes found three more: the .deb postinst runs adduser but the package did not depend on it, which container base images do not ship (Depends: adduser, tooling test added, RED 1); gcc at -O2 flagged the thumbnail cache's eviction loop under -Werror=null-dereference (the iterator is now checked against end()); and six tests added on this branch used std::jthread, which the FreeBSD and OpenBSD libc++ lack (now JoiningThreads or std::thread with an explicit stop flag). The CRY-4 scenarios that read kernel lock state from /proc/self/smaps, or drop RLIMIT_MEMLOCK to make a lock fail, are skipped under AddressSanitizer, whose mlock interceptor locks nothing and never fails. The ISO-5 decoder-stderr scenario compares stderr with /dev/null only on Linux: NetBSD's /dev/fd/2 is a static device node without fdescfs, so -ef could not compare it; the write and the empty parent capture still prove the property everywhere.
  • FIXED (PR #509 review, auth): the token-key cache dereferenced an empty optional when the first master-key open failed and a second open by path succeeded, and every authenticated request copied both token keys into fresh locked allocations, which could exhaust RLIMIT_MEMLOCK under load and turn auth into 503s. The cache moved into crypto::cached_token_hmac_keys: one open per lookup, keys derived only from that open, shared (not copied) on a hit, and an unreadable file logged once per outage rather than per request. Copying a moved-from SecureKeyBytes now throws instead of yielding a valid all-zero key. Tests: new test_crypto_token_key_cache.cpp ([cry4][token_key]); the [cry6-lock] scenario now asserts that issuance with no locked-memory budget left succeeds from the already-locked cached keys, where it used to expect a 503 from the copy.
  • FIXED (PR #509 review, federation): GET /backfill/{room}?v=%00 threw out of parse_backfill_query (the path decoder throws on NUL and the transport's guards stop at ?), dropping the connection instead of answering; the parser now refuses a NUL anywhere in the target (ADR-0137). Every security.federation.deny_ip_ranges entry must now be a well-formed address or CIDR range: the boundary treats a malformed entry as denying every address, so a typo such as /33 silently stopped all federation. A transaction's sender-key pre-pass no longer runs more resolutions at once than the runtime's in-flight cap, which admission refuses rather than queues: with four worker shards (cap 4) any transaction naming five or more cold sender servers used to deny itself a 503 (federation::sender_key_prepass_parallelism). /send_join state verification called the raw key resolver up to 1024 at a time, outside every FED-10 budget, so a resident server could name thousands of servers and drive a burst of fetches; it now takes main's shared in-flight slots and honours the server/key failure cache through federation::resolve_join_state_key, waiting for a slot (until the join timeout) instead of being refused, and is not charged a per-source rate, since a refused key would drop a member event from the joined room's state (ADR-0155). Tests: [nul] backfill scenario, test_config_parser.cpp deny-range scenario, [fed10-shards] parallelism scenario, [send_join][fed10] shared-cap and failure-cache scenarios (RED: peak above the cap; a failed pair fetched twice).
  • FIXED (PR #509 review, media): after a decode, the record was checked before the media policy, whose policy-server round trip releases the runtime mutex, so a quarantine landing during it was missed and the thumbnail was served and cached; the policy is now asked first and the record checked last. security.media.thumbnail_cache_size=0 failed validation although the manual and ADR-0152 say it disables the cache. Cache keys clamp each axis to the worker's 4096 maximum, so width=5000, 5001, ... no longer fill the cache with copies of one thumbnail. The thumbnail cache size and per-user decode cap are restart-required: nothing rebuilt them on reload. Tests: integration [med-4-policy-order]; unit [med4] cache-size and dimension scenarios; reload policy.
  • FIXED (PR #509 review, push): the gateway circuit counted 5xx answers and was keyed by the raw host, so one user's pusher with bad data on a shared gateway could close it to every user for the cooldown, and case or trailing-dot spellings had separate circuits. Only failures to reach the host (DNS, connection, TLS, timeout) count now (push::gateway_circuit_outcome), and the key is the case-folded host:port. A rejected list now removes only a pusher whose own pushkey it names; any non-empty list used to delete the requesting pusher. ADR-0151 amended. Tests: [out3] classification, key and rejection scenarios; integration: an unreachable host is skipped after three failures, a host answering 500 is still contacted, a rejection of another pushkey keeps the pusher.
  • FIXED (PR #509 review, database): SQLite refused any symlinked ancestor directory, so a data directory linked to another volume stopped the server starting; existing directories are now followed, the database file itself must still not be a symlink, and everything below a directory the server creates is opened without following links. SQLite is handed the database's real directory, since SQLITE_OPEN_NOFOLLOW refuses a link anywhere in the name it is given. ConnectionPool::acquire leaked its slot if opening or probing a connection threw, and a wait for a slot has no timeout. Tests: [db10] symlinked data directory scenario; new test_database_connection_pool.cpp ([db7][pool]).
  • FIXED (PR #509 review, operations): a refused --log-file now says so on the console (SingleLog::set_log_file_path returns whether the file opened). The startup hardening gate also requires the federation worker to be spawned and still running a few seconds later (--worker-settle, default 3 s), and rejects a non-numeric --timeout, port or settle time. ADR-0142's verification section contradicted its status and is rewritten; internal agent and process IDs are removed from this changelog and the audit report. Tests: test_startup_hardening_gate.py (worker dies, worker never starts, non-numeric timeout), [iso6] refused-path scenario.

  • FIXED (DB-7): every persisted write opened a fresh database connection (an SQLite open with its no-follow checks, or a PostgreSQL connect, TLS and SET ROLE), usually with the runtime mutex held. Writes now lease connections from a per-store database::ConnectionPool capped at database.pool_size (16), which is now restart-required; a write waits for a free connection rather than opening another. A PostgreSQL connection takes the runtime role once and is reused only while idle with nothing to read on its socket, so one the server has closed (restart, idle reaper) is replaced instead of failing a write; reads and migrations are unchanged (ADR-0154). Also fixed: PersistentStore's copy constructor and assignment did not copy postgresql_runtime_role, so a copied store would have written as the login role; and the PostgreSQL signing-key restart test wrote its URI file group-readable, which the ISO-7 reader rightly refuses (it only runs with MEROVINGIAN_TEST_POSTGRESQL_URI). Tests: new test_security_audit_write_pool_flow.cpp ([db7], 64 concurrent writers on real SQLite and PostgreSQL with a limit of four, then every pooled backend killed). RED: the pool API did not exist; a first version that trusted PQconsumeInput lost 4 of 64 writes after the kill, which the socket-readiness check fixed.

  • ACCEPTED (FED-12, DB-6, DB-8, DB-9, OPS-2, OPS-4): implemented earlier on this branch (entries below) and now accepted: each fix was checked against its finding, and the regressions re-run at cc7e1ee7: unit [fed-12-live-context] 49 / 2, [ops2] 101 / 2, [ops4-negative-lifetime] 20 / 1, [ops-4-upper-bound] 146 / 1, [db6] 9 / 3, [db8] 48 / 2, [db9] 6 / 1; integration [fed-12-origin-binding] 109 / 1, [ops2] 44 / 2, [db6] 2909 / 15; conformance [ops-4] 223 / 1. ADR-0137, ADR-0138, ADR-0139 and ADR-0142 are accepted. The audit report now lists every low finding as fixed.

  • FIXED (FED-10): the last part of FED-10, aggregate admission across worker shards. Each federation worker shard had its own copy of the key-resolution budgets (key_resolution_max_in_flight, key_resolution_per_ip_rate), so federation.worker.shards workers allowed that many times the configured outbound key fetches. A worker now starts with its share (federation::shard_key_resolution_budget: the limit divided by the shard count, rounded down, at least 1) through RuntimeStartOptions::key_resolution_shards; main keeps the whole budget (ADR-0153). Tests: new test_security_audit_fed_10_shards.cpp ([fed10-shards]). RED: the split function and the start option did not exist, so every shard started with the full budget.

  • FIXED (MED-4): thumbnails were decoded with the lock released (0.12.20) and local ones revalidated after the decode (ADR-0145), but every request forked the decoder again, one client could run any number of decodes at once, and a remote thumbnail was not revalidated. New media::ThumbnailCache keeps generated thumbnails in memory (security.media.thumbnail_cache_size, 64 MiB, least recently used first out), served only after the source record is checked as servable. New security.media.thumbnail_max_concurrent_per_user (2) caps decodes in flight per user (or client address when unauthenticated); beyond it 429 M_LIMIT_EXCEEDED. A remote thumbnail now carries the cached local record its bytes were admitted as and is revalidated like a local one, and every thumbnail re-asks the media policy after the decode (ADR-0152). Also fixed: make_runtime_media_config's positional initializer put the thumbnail timeout's 10 into thumbnail_worker_fd, so on FreeBSD a failed worker pre-open left fd 10 to be fexecved; it is now -1. Tests: [med-4-cache], [med-4-concurrency] and [med-4-remote] in test_media_thumbnail_revalidation_flow.cpp (real worker and gate) and new test_media_thumbnail_cache.cpp. RED: with the worker gone a repeated thumbnail was 404, a third concurrent decode for one user entered the decoder, and a remote thumbnail was served after its cached record was quarantined.

  • FIXED (OUT-3): one user's push gateways could still tie up delivery: registrations were unbounded and a gateway host that stalled or failed was contacted again for every event, holding a delivery slot for its full timeout. New server.push.max_pushers_per_user (20): POST /pushers/set refuses a new pusher beyond it with 400 M_INVALID_PARAM (updates and deletes still allowed). New push::GatewayCircuitBreaker: after server.push.gateway_failure_threshold (3) consecutive transport failures, timeouts or 5xx responses, a host is skipped for server.push.gateway_cooldown_seconds (300), then probed once per cooldown; skipped deliveries log push.delivery.circuit_open. The per-recipient in-flight cap already existed and now has a test. ADR-0151 supersedes ADR-0029's "registration stays unbounded". Tests: three [out3] scenarios in test_push_delivery_flow.cpp and new test_push_gateway_circuit_breaker.cpp. RED: a 21st pusher was accepted and all five notifications reached a gateway answering 500.

  • FIXED (ISO-7): the remaining two re-opens of a secret are gone. The TLS private key was checked at startup by path and then loaded by OpenSSL from the path again (SSL_CTX_use_PrivateKey_file); make_tls_server_context now reads it through one validated descriptor (O_NOFOLLOW, regular, owned by this user, mode 0400) into locked memory and gives OpenSSL a memory BIO. The signing-secret-box and token-HMAC caches took the master key file's identity from one open and its bytes from another, so a swap in between could cache a key from one file under another's identity; new crypto::open_master_key_file returns the descriptor and identity together, and both caches read the bytes from it. Test fixtures now write TLS keys 0400, as production already required. Tests: new test_security_audit_iso_7.cpp ([iso7]). RED: a 0640 key and a symlinked key were both loaded; the single-open master-key API did not exist.

  • FIXED (EVT-12 (d)): room versions 3-5 refused a float power level, which rooms/v3.md allows ("exponential notation is applied ... the value is truncated at the decimal point"). New RoomVersionPolicy::power_levels_accept_floats (v3-v5) and events::PowerLevelEncoding replace the allow_string_values flag, so the auth rules, the state resolver's power ordering, redaction judgement and push all read integers, integer strings (v1-v9) and truncated floats (v1-v5) by the room's own version. Infinity, NaN and values beyond int64 are refused. Hashing is unchanged: Canonical JSON defines no float encoding, so such an event still cannot be parsed for its event ID or content hash (ADR-0150). Tests: three [evt12] conformance scenarios in test_event_auth_rules.cpp (v5 truncation, v4 1.0E2 honoured, v6 refused); the unit characterization in test_event_auth_rejected_fallback.cpp now asserts the new contract. RED: both v5 acceptance cases were refused.

  • FIXED (DB-4): a ban could commit while the banned user's membership row still said join, and every restart rebuilt the room's roster from that row, so the user could read /joined_members again. Every path that stores an m.room.member event wrote the event and its current_state row in one transaction and the row and invite metadata in later ones. The row and invite change are now a database::MembershipProjection committed in the event's own transaction on both backends: local membership changes (persist_composed_event), the federated join_room, inbound send_join/send_leave/send_knock, /invite and PDUs over /send. A federated leave, and /invite for a room this server holds, write row and invite together with store_membership_projection. At startup hydrate_local_database reconciles the rows from current state before building the roster: a row is rewritten when it is missing, says join while the current member event does not, or is older than that event; a newer row (a federated leave or an /invite whose event has not arrived) is kept. Inbound PDUs no longer log and drop a failed membership write after accepting the event. ADR-0149 (refines ADR-0126); no schema change. Tests: new test_security_audit_membership_atomicity_flow.cpp ([db4]: a tampered SQLite store regains no access after a restart; a SQLite trigger that refuses bob's row leaves no ban event or state; the PostgreSQL variant, gated on MEROVINGIAN_TEST_POSTGRESQL_URI, with a real trigger) and new test_security_audit_db_4.cpp (reconciliation rule and projection atomicity on the in-memory store). RED: after a restart bob's /joined_members returned 200 and alice's roster listed him, and with his row refused the ban event and its current state were still durable (5 failed assertions). Review follow-up: the reconcile first ran for every load profile, so the federation worker's open also rewrote rows and drew stream positions from its own counter; on PostgreSQL that durably wrote main's membership table. It now runs only for TableLoadProfile::full (main), like the redaction reconciler; main reconciles before it spawns the worker. New [db4-worker] scenarios (SQLite and gated PostgreSQL); RED: the PostgreSQL worker open rewrote bob's row to ban, and the SQLite worker open rewrote it in memory and allocated a stream position (3 failed assertions).

  • FIXED (OPS-5): the Docker image and the deb and BSD packages built at Meson's default debug buildtype (-O0), so _FORTIFY_SOURCE was never defined and every shipped server refused to start at its startup hardening gate. scripts/build-deb.sh, build-freebsd-pkg.sh, build-netbsd-pkg.sh, build-openbsd-pkg.sh and the Dockerfile now pass --buildtype=release; meson.build keeps the debug default for development builds and warns at configure time when hardening meets optimization=0 (ADR-0148). The Dockerfile pins debian:trixie-slim by index digest in both stages, installs libpq-dev/libpq5 and the other libraries the binaries link (with trixie's t64 runtime names), ships merovingian-db-migrate, merovingian-fed-worker and merovingian-thumbnail-worker, and fails its own build if ldd finds an unresolved library; a new .dockerignore keeps local build directories out of the context. New scripts/check-startup-hardening-gate.sh boots a packaged server against a throwaway SQLite config and passes only when every hardening check reports enabled, the count matches, and the server exits 0 on SIGTERM. packages.yml runs it against the installed .deb in the deb and debian-pkg jobs (as merovingian, setpriv --bounding-set=-all) and inside the image in a new docker-image job (--cap-drop=ALL --security-opt=no-new-privileges). The BSD package jobs do not run it yet. Tests: test_packages_workflow.py gains six scenarios (buildtype per script, digest pins, Dockerfile build flags and libraries, shipped workers, CI wiring); new test_startup_hardening_gate.py (registered as startup-hardening-gate-tooling) covers the gate's eight decisions against a fake server. RED: 18 packaging subtests failed and the gate script did not exist. Run against a real --buildtype=release -Dhardening=true server (non-root, empty bounding set via a user namespace) the gate first failed because the master key shared the config's directory, which the federation worker's Landlock guard rightly refuses; config, database and key now get separate directories (new scenario, RED then GREEN). It then passed with all 15 checks enabled and a clean SIGTERM exit, and against a debug build it failed on FORTIFY_SOURCE=unknown and compiler hardening=unknown, the audit's reproduction. Full hardened WSL run at the first commit: 56 OK, 1 Fail (conformance-tests: "GET /sync returns spec-shaped room category objects" gets a 500 from createRoom with an invite; it fails 2 of 20 times run alone and is unrelated to this change, which touches no C++), 0 timed out.

  • FIXED (ISO-8): the federation worker's seccomp profile argument-checks socket(): AF_UNIX, AF_INET and AF_INET6 are allowed, AF_NETLINK answers EAFNOSUPPORT so glibc's getaddrinfo address-sorting probe falls back, and any other family (AF_ALG, AF_PACKET, AF_VSOCK, …) kills the worker; socketpair() is AF_UNIX only. The systemd unit adds RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6, RestrictNamespaces=, ProtectKernelTunables=, ProtectKernelModules=, ProtectKernelLogs=, ProtectControlGroups=, ProtectClock= and ProtectHostname=. The docs now say why main's PostgreSQL role must not use peer authentication. RED: [iso8] failed 5 of 35 assertions (healthy families passed); GREEN passes 60 / 2, [seccomp] 367 / 15 and [iso2] 30 / 1, in a fresh full WSL run of 56 OK / 0 failed / 0 timed out. ADR-0147.
  • FIXED (ISO-6, log file): --log-file is opened by the new header-only observability::LogFileSink with O_NOFOLLOW|O_CLOEXEC|O_NONBLOCK, used only when it is a regular file this process owns, then made 0600 and truncated (as before). A symlink, FIFO, device or foreign file is refused and the logger writes no file output. RED: the logger wrote through a symlinked log path (1 of 35 [iso6] assertions failed); GREEN passes [iso6] 35 / 6, [iso6_defaults] 32 / 2 and [logger] 156 / 8 in the same full run. ADR-0146. Together with the umask, package-script and SQLite-creation changes already on this branch, ISO-6 is closed.
  • ACCEPTED (ISO-4, DB-10): reviewed the checkpoint's SQLite changes. The worker opens main's SQLite file read-only, refuses a WAL-mode file, and its Landlock grant on the database directory is read-only; main creates missing directories 0700 and the database 0600 without following symlinks, tightens an existing file it owns to 0600, and sets secure_delete=ON and trusted_schema=OFF. The threat model's stale "read-write" Landlock wording is corrected. Residual: the database path is reopened by name after the descriptor-relative checks, so a writable parent directory still allows a swap between them. [iso4] 15 / 2 and [db10] 15 / 2 pass in the same full run.
  • ACCEPTED (MED-4, local thumbnail revalidation): local thumbnail requests own the source media/storage identity across unlocked blob reads and decoding. Successful worker output is checked against that record's current servability, storage binding and legacy visibility before publication or served accounting; a quarantine or removal wins with 404 even when an available alias shares the same blob. A real SQLite/socket regression gates a child before it execs the actual worker, proves healthy ungated/gated output and concurrent /versions and admin operations, then checks durable quarantine/removal and alias safety. Genuine RED passes 249 / 255 assertions, failing the six expected PNG-disclosure checks across both actions. Normal GREEN passes 255 / 1 inside a fresh full WSL run of 56 OK / 0 failed / 0 timed out. Output caching, per-user decode admission and remote source identity remain separate MED-4 work. See ADR-0145.
  • ACCEPTED (MED-7, bounded admission repair): federation media downloads now consult the existing media-policy decision before repository resolution or blob reads. A denial returns 403 with no media bytes or content type, and the federation dispatcher returns its existing JSON error rather than a multipart success. Real SQLite integration covers persisted admin denial, genuinely signed requests, an unaffected upload and recovery after rule removal; a second scenario checks the configured unavailable policy hook and explicit fail-open setting without an injected callback. RED passed 116 / 129 assertions, failing the 13 expected refusal checks; final normal GREEN passes 132 / 2. Related media integration passes 371 / 9, locking 146 / 7, federation media conformance 12 / 1, client policy 41 / 3 and trust-safety units 100 / 13. Independent review passes; the fresh normal full suite completes 56 OK with every return code zero and 1,026 frozen files unchanged. Live remote-policy HTTP responses and strict policy revocation during an admitted request are not established. This accepts the bounded admission repair, not the whole audit or release. See ADR-0144.

  • FIXED (MED-8): the thumbnail worker checks a crop's actual fill against the existing effective pixel budget in 64-bit arithmetic before narrowing or allocating. Zero still uses the 4,096,000-pixel worker ceiling; a larger request cannot raise it. Refusal uses the existing empty too_large frame (413 from generate_thumbnail), not an allocation failure or worker crash. Main prepares an empty internal_error frame after hardening and before input, then catches unexpected C++ exceptions without allocating a replacement error string. A safe real-worker RED passed 115 / 163 assertions, failing only the 48 expected crop checks with all healthy controls passing. Normal GREEN passes 201 / 3; related thumbnail integration 776 / 10, ISO-5 isolation 554 / 4, thumbnail units 35 / 8 and hardening units 21 / 10. The original 64 GiB amplification inputs run only as first-GREEN coverage. The fresh full WSL run completes 56 OK / 0 failed / 0 timed out, with the MED-8 tag re-run at 201 / 3; separate exception fault injection and broader thumbnail geometry/upscaling conformance are not claimed. See ADR-0143.

  • FIXED (HTTP-7): permissive JSON parsing checks integer bounds before converting unsigned values to signed values; values outside the canonical integer range are refused with integer_out_of_range instead of wrapping.
  • FIXED (EVT-10): an invite with any third_party_invite property enters the third-party authorization branch; malformed/non-object values cannot fall through to ordinary invite authorization.
  • FIXED (EVT-11): legacy room versions honor string-valued thresholds in the nested events power-level map for both state and message events, including the spec's allowed whitespace, sign and leading zeroes.
  • PARTIAL (EVT-12): the v3–v5 aliases sender-domain rule runs before membership/power checks; receipt-rejected events cannot be selected as named inbound auth events or as own-auth fallback during state resolution. Trusted rejection metadata is carried outside the event JSON and retained on materialization. Rejected candidates remain available for auth-chain traversal and reauthorization. Legacy fractional power-level parsing remains unresolved; the characterization test is not a closure claim.
  • FIXED (OUT-6): server discovery retains at most 1024 positive/negative entries by default (constructor capacity zero disables caching), sweeps expired entries before hits and insertion, rejects invalid server names before network/cache admission, and bounds concurrent insertion. Oldest results are evicted at capacity.
  • FIXED (OUT-8): configured IPv4/IPv6 literals and CIDRs supplement the mandatory SSRF blocks throughout discovery, cache, well-known pre-connect and filtered destination resolution. The production discovery network owns the startup policy, including in workers.
  • FIXED (CRY-3, CRY-4): master roots shorter than 32 bytes or entirely zero are refused; secret buffers and every derived-key/cached-key copy use isolated sodium allocations with independently verified locking. Exhausted locked memory returns no key. Historical BLAKE2b inputs and labels are unchanged so stored signing-key ciphertext and token digests stay compatible; the audit's proposed in-place KDF switch is rejected in ADR-0128 rather than silently invalidating persisted data.
  • FIXED (CRY-5, CRY-6): validation-session lookup and consumption use the variable-length constant-time comparison boundary. New access/refresh token issuance requires v4 and returns 503 on unavailable keys without adding credentials; failure to copy a cached key into locked memory is covered explicitly.
  • IN PROGRESS (FED-12): reproduced wrong-origin and completed-parent EDU persistence through real signature verification, authenticated encrypted IPC and reopened SQLite: normal build succeeded; 97 / 109 assertions passed and 12 behavioral checks failed, with healthy delivery and signature controls passing. Main-side remediation binds owned verified origin to the exact channel's live pending parent and checks admission after the runtime-lock wait. Ordinary requests cannot authorize a relay; pending locks never cover a sink. Worker parent propagation and legitimate-fixture migration are implemented. Normal post-format GREEN passes 109 assertions / 1 regression case; related worker EDUs pass 173 / 5 and worker flows 2059 / 38. The actual socket-HTTP / separate-worker mixed PDU/EDU control passes 262 / 1; proxy checks pass 12 / 2. Supplemental lifecycle checks pass 49 / 2 (first-GREEN coverage, not a new RED), IPC units 396 / 50, worker units 340 / 42 and sync handlers 382 / 16. All 79 focused source bindings are stable; the 11-file owned-added-lines scan has no pattern indicators and all 11 owned range-format checks pass. Independent source review and fresh combined acceptance remain pending. This is not whole-finding/release closure or signed-body binding. See proposed ADR-0142.
  • PARTIAL (OPS-4): configuration parsing and typed validation now reject negative access and refresh token lifetimes with field-specific findings rather than silently disabling expiry. Zero remains the explicit no-expiry setting; defaults and full signed numeric parsing are preserved. The actual parser regression reproduced 12 rejection failures / 14 assertions with both healthy control assertions passing before repair; it now passes 20 assertions / 1 case including explicit default/zero controls. Signed-boundary checks pass 4 / 2 and normal configuration units 845 / 176 against 41 unchanged focused sources. Stored sessions, expiry text and token digests are unchanged. Independent source review passed without security or logic findings; parent inspection bound the reviewed files to executed GREEN and verified preservation of prior shared edits. The fresh combined test checkpoint passed 56 OK targets with all return codes zero, no failures/timeouts and 150 unchanged snapshotted files including all 41 focused sources. The source freeze ended after parent inspection of completed archives and current-source binding. After renewed explicit authorization, the added-line security scan checked both changed C++ files and reported no findings; all 41 focused code/test sources matched the reviewed and tested state at that checkpoint. The bounded negative-admission slice is accepted. Refresh-enabled /login and /refresh now omit expires_in_ms when the access lifetime is zero, as Matrix v1.19 requires; finite lifetimes retain the positive expiry field. A new real SQLite/public-dispatcher conformance scenario reproduced both omission failures (221 passing / 2 failing assertions) before repair and now passes all 223 assertions / 1 case, including finite, stored-expiry, token-usability and legacy-login controls. Related auth conformance passed 243 / 4; all 48 focused sources were unchanged during those runs. The added-line/complete-new-file security scan reported no findings in both C++ files, and inverse-hunk checksum verification confirms all preceding shared production edits are preserved. Bounded independent source review passed with no security, logic or suggested changes. Parent inspection bound the reviewed production and regression to executed GREEN and verified that the later OUT-3 registration entry preserves the entire earlier Meson file. Fresh current-source conformance again passed 223 / 1 and related auth 243 / 4 after the later OUT-3 and configuration changes. The full checkpoint below accepts this bounded response slice; its earlier 48-source snapshot remains historical, not a current whole-tree claim. Positive lifetime admission now has project ceilings of 2,592,000,000 ms (30 days) for access and 31,536,000,000 ms (365 days) for refresh. A registered real parser/direct-typed matrix reproduced 20 missing-rejection failures with 126 passing assertions before repair, covering both fields, zero, one, defaults, inclusive maxima, maximum-plus-one and the signed maximum. Exact typed values survive parsing and validation even when policy rejects them. No stored sessions are rewritten. ADR-0141 records these project limits, not Matrix requirements. The upper-bound regression now passes 146 assertions / 1 case; negative admission passes 20 / 1, signed-boundary parsing 4 / 2 and normal configuration units 991 / 177. The corrected exact-tag run completed with all statuses zero and 61 sources stable during execution. A two-file added-line/complete-new-test scan reported no indicators; inverse-boundary checksums verify preceding shared production and registration edits are preserved. Independent source review passed without security, logic or suggested changes; parent checks bind all three reviewed source/registration hashes to executed GREEN. The fresh full checkpoint passed 56 OK targets, all return codes zero and no failures, timeouts or other result categories, with 170 files unchanged throughout execution including all 61 focused sources. Parent independently reconciled fresh archived JSON/text logs and current reviewed source bindings, then released the freeze. The bounded upper-admission slice is accepted; subsequent changes are acceptance-status docs only. Checked timestamp arithmetic, direct typed runtime snapshot/reload admission, registration response expiry and broader persisted hydration remain unresolved or unverified; no whole-finding/release closure.
  • PARTIAL (OUT-3): added a real SQLite/public-dispatcher/loopback-TLS recipient-isolation regression. Its healthy notification control, ten held attacker gateways and cleanup passed before repair, while the other recipient's notification failed (262 passing / 1 failing assertion). Delivery now uses independently admitted recipient batches, a global bound and a per-recipient cap of G > 1 ? min(8, G - 1) : 1, retaining valid serial behavior at G=1. Move-only RAII permits release both atomic counters on completion and unwind; future parking capacity is reserved before launch. No configuration key or persistent pusher format changed. Normal integration now passes 263 assertions / 1 isolation case and 1,092 / 18 related push cases, with all 60 focused sources stable during execution. A four-file added-line/complete-new-test scan reported no indicators, and inverse-boundary checksums prove preceding shared production edits are preserved. ADR-0140 records the policy. Independent source review passed with no security or logic findings; parent checks bind all four reviewed production/test hashes to executed GREEN and the scan. The same fresh 56-OK, 170-stable-file full checkpoint above includes all four reviewed files and accepts the bounded recipient-isolation slice. Nonblocking suggestions cover fault-injected allocation/launch/ work failures, zero/single/eight-slot boundaries, concurrent admission and churn. Gateway circuits, registration quotas, mixed-recipient execution coverage and whole-finding/release acceptance remain unestablished.
  • PARTIAL (DB-8): preserved the existing remote verified-TLS guard and repaired its parent-default dependency. Remote connection options must now explicitly select the verified mode; an omitted or empty mode cannot pass only because the parent supplies PGSSLMODE. Local socket/loopback exemptions, host/host-address checks, service refusal, private readers, roles and stored formats are unchanged. A deterministic URI/keyword regression with healthy controls failed 4 / 28 assertions before repair and passes 28 / 1 afterward; all DB-8 checks pass 48 / 2, DB-9 checks 6 / 1 and database units 18363 / 174. The user manual and persistence guide now document strict remote admission, trust provisioning and the minimal worker environment; ADR-0138 records why forwarding parent defaults or adding insecure opt-outs was rejected. Earlier DB-8/DB-9 RED/GREEN logs were recovered as historical evidence. Independent review passed with no security or logic findings; parent inspection bound the reviewed files to executed GREEN and checked preservation of prior shared edits against accepted source checksums. The fresh combined checkpoint passed 56 OK targets with all return codes zero, no failures/timeouts and 135 unchanged snapshotted files. The source freeze ended after acceptance. Normal DB-6 integration compatibility passes 2733 / 15; both isolated PostgreSQL modes pass 178 / 2 with verified shutdown/owned-fixture removal and tested-source binding. TLS handshake/certificate/hostname and worker trust-file access are not established by these tests. No whole-finding/release closure.
  • PARTIAL (DB-9 verification): retained the existing fixed PostgreSQL diagnostic identity and generic parser/connection-status refusal reasons; no new production defect was identified in the inspected opener paths. Added registered actual DEBUG-console integration coverage for malformed quoted options and four policy-admitted quoted/spaced/escaped/URI forms reaching real libpq failure at an owned private nonexistent socket path. Runtime-only whole/partial credential markers are absent from emitted records and returned reason/identity, with event-presence controls and environment/stdout/owned-path cleanup checks. The new tag passes 85 assertions / 2 cases and normal database integration 2628 / 58 against 30 unchanged current sources; existing DB-9 units pass 6 / 1. This is first-GREEN coverage, not a new RED/production-fix claim. Persistence/dependency docs and ADR-0139 explain why all connection options, including host/database values, remain suppressed rather than reconstructed. Bounded independent source review passed without security or logic findings; parent inspection bound the reviewed files to the same executed 30-source manifests. Current database units pass 18363 / 174, DB-8 units 48 / 2 and DB-6 integration 2733 / 15. Fresh combined acceptance passed 56 OK targets with all return codes zero, no failures/timeouts and 139 unchanged snapshotted files including all 30 focused sources. Source freeze ended after parent inspection of completed archives and current-source binding. Native Windows socket execution, live authentication/TLS/ready paths, allocation and startup fault injection, and general SQL-result error confidentiality remain outside this coverage. No whole-finding/release closure.
  • PARTIAL (ISO-7): bounded master-key, registration-token, main/worker database URI and bootstrap-password reads validate ownership, mode and regular-file type after O_NOFOLLOW|O_CLOEXEC open and read the validated descriptor directly into locked storage. Remaining TLS/private-key and cache identity/load race review is not a closure claim.
  • FIXED (OPS-6): the bootstrap password is a bounded, service-owned mode-0400 non-symlink file read into locked storage and wiped after bootstrap. The manual now uses a private temporary directory and correctly states that bootstrap continues serving.
  • TESTS (crypto batch): parent rebuilt focused crypto/auth regressions passed 777 assertions / 102 cases, including historical derivation compatibility and forced locked-memory exhaustion for both caches. The new exhaustion regressions were observed failing before their fixes. Combined-suite coverage is recorded in the successful post-repair checkpoint below; the remaining audit pass is not complete.
  • FIXED (ISO-5): thumbnail exec receives only a fixed PATH, with stderr redirected to a pre-opened, RAII-owned /dev/null sink on both exec paths. Parent-side PNG signature and initial IHDR dimension checks refuse malformed or mismatched output with 502 and no media payload. No decoder or image-data validation moves into main.
  • FIXED (FED-9): only an explicitly present, empty pdus array enables the EDU-only /send bypass. A malformed first PDU followed by real PDUs retains an empty shard key and enters the worker instead of main; later PDUs are not used to select a main-process failed-sync fallback. Parent source/proxy review and independent normal-binary runs passed: 27 assertions / 1 [fed9] case and 167 assertions / 26 [routing] cases. ADR-0131. No dedicated live IPC regression ran.
  • PARTIAL (FED-10): relayed sender-key resolution now shares the source-address rate budget, runtime-wide in-flight cap and server/key negative cache across parallel pre-resolution and authorization fallback paths. A network-free key-only provider preserves cached verification, retained local keys and known-peer rotation; fresh keys no longer exempt production discovery. Raw/verified router paths retain source identity. Cache-only origin lookups cannot release another request's slot; asynchronous resolver exceptions release scheduling permits and keep callable lifetimes valid. Normal-target verification passed 474 assertions / 5 unit cases, 41 / 3 real database/router integration cases and 963 / 54 related inbound cases. Budget, cache, exception, concurrency and production-wiring behavioral RED evidence is retained. ADR-0132. The reviewed overload-to-trust/replay regression is repaired: explicit outcomes preserve local denial across parallel, direct and restricted-join paths; 503 leaves the original transaction retryable without charging or resetting trust. Actual/negative-cache failures retain penalties in mixed transactions, and EDUs wait for retry. ADR-0133. Independent all-target build and fresh normal-binary runs passed 681 assertions / 9 unit cases, 122 / 4 real database/router integration cases and 1170 / 58 related inbound cases. Behavioral RED evidence is retained. Independent review confirmed the core overload repair; the completed unified WSL checkpoint passed 56 targets with zero failures/timeouts. Its separate main IPC bypass is now repaired: PDU, membership and invite re-verification share the admitted key-only helper, with all main relay misses using the common unknown source quota rather than a worker-supplied address. Local denial returns the existing PDU overload status or unaccepted 503 before mutation. A genuine encrypted-IPC RED failed 94 assertions before implementation. Normal integration runs then passed 1228 assertions / 3 new cases, 156 / 3 existing signature cases and 1350 / 7 FED-10 cases. Cache identity/event-time validity, unchanged-relay retry, foreign slot ownership and bounded pair negative caching are covered. ADR-0134. A mixed negative-cache/EDU unit regression also exposed genuine trust penalties being discarded by a later main-overload return. Its behavioral RED failed one assertion; conditional persistence now retains only actual failures, defers EDUs and leaves the unchanged transaction retryable. Final normal unit FED-10 passed 716 assertions / 10 cases and related inbound 1205 / 59. The first main full run had 55 OK / 1 failed fixture; its 50-PDU room-interest regression now reads the already-known key from cache instead of spending discovery quota. Production limits are unchanged. Fresh completed WSL CLI passed 56 targets, zero failures/timeouts, with one skipped unit case; logs are archived and the six focused source/test hashes remain unchanged. Independent source-only review approved this main slice with no material findings. End-to-end worker HTTP overload propagation remains a coverage gap: the IPC fixture substitutes PDU result serialization; production serialization, worker decoding and HTTP propagation were source-traced only. Backfill/snapshot/auth-chain key lookup now shares the admitted key-only helper and unknown-source bucket. Local denial stops subsequent recovery calls and preserves the existing PDU overload result instead of accepted missing state; already-verified outliers are not rolled back. Genuine TLS/ SQLite RED failed 13 of 172 assertions before the fix; normal post-format integration passed the new 172 / 1 case, all backfill 2173 / 17 and total FED-10 1522 / 8. ADR-0135. Fresh post-backfill full CLI completed exit 0: 56 OK targets, zero failures/timeouts, one skipped unit case; archived logs and eight unchanged source/test hashes confirm the tested snapshot. Material review of the original production slice is accepted. The two later test delegates failed provider limits, leaving unfinished fixtures; their edits were preserved, inspected and repaired directly without loosening production checks. Independent post-format normal integration passed actual worker/socket-HTTP overload and unchanged-transaction retry (262 / 1), sticky recovery denial (200 / 1), partial-progress retry/deduplication (314 / 1), all backfill (2687 / 19) and FED-10 integration (2298 / 11). The fresh recovered full checkpoint passed 56 OK targets, all return codes 0 and zero failures/ timeouts, with tracked source unchanged. This supplies separate-process production serializer/worker decoder/HTTP execution evidence, not merely the earlier substituted IPC fixture. Direct state/auth-chain denial and backfill cache rejection/negative-hit cases remain unexecuted coverage suggestions; aggregate cross-shard limits and whole-finding acceptance are not established. No BSD claim.
  • PARTIAL (DB-6): local composition now uses the same NUL state-key admission rule as inbound parsing, before allocating a stream ordering. createRoom preflights the complete initial-state list before persisting room/bootstrap state, including a bad entry after a valid one. Direct local state sends answer 400 M_INVALID_PARAM, not a late database refusal or a successful in-memory mutation. Genuine local-admission RED failed 42 of 259 assertions; the repaired normal integration group passed 259 / 3 cases. Public client and local-router entry points also reject literal or once- decoded NUL in the path before a decoder can throw, with controlled 400 M_INVALID_PARAM; public responses retain configured CORS. The query suffix is not part of this path guard, and %2500 is not double-decoded. Genuine HTTP RED failed 24 of 64 assertions; original repaired admission passed 82 / 1 case and compatibility 4 / 1 (all DB-6 integration 345 / 5). Additional explicit local errcode and configured Origin/Vary CORS checks now pass 100 / 1. Source tracing after an incomplete independent review exposed the same exception in recognized via/server_name join queries: genuine RED failed 16 of 47 assertions; checked optional parsing now returns 400 M_INVALID_PARAM, with normal GREEN 63 / 1 and ordinary, %2500 and unrelated %00 query positives. Local admission remains GREEN 259 / 3. Real isolated PostgreSQL 16 inbound/compatibility tests passed 180 / 2, both initially and after the join-query repair: a signed control persists, NUL state keys cause no statements/event/state/graph/watermark writes, and Unicode state keys, escaped-NUL content and binary BYTEA remain intact. The private Unix-socket-only test database is stopped and removed after each run; 14 tracked admission-source hashes remain stable. Initial post-format unit DB-6 passed 9 / 3, query 127 / 12 and routing 167 / 26. Startup/compilation failures were repaired as fixture/build failures, not counted as behavioral RED. The first independent source review withheld approval after a tool approval timeout, with no established implementation defect; a bounded re-review is pending. Real no-proxy federation socket- HTTP RED failed 16 of 216 assertions with authenticated controls reaching the handler. The federation entry point now rejects NUL paths before routing/replay with a structured 400 M_INVALID_PARAM: normal GREEN passed 246 / 1, including nine leading/interior/trailing malformed room, member and event ID cases, same-socket keep-alive controls, %2500 positives and unchanged memory/reopened SQLite rows and watermarks. The accompanying HTTP/local/join groups still pass 100 / 1, 259 / 3 and 63 / 1; 19 tracked source hashes remained stable during that run. A second real healthy-proxy/separate-worker socket regression then exposed main's earlier shard-key decoder: genuine RED failed 7 of 293 assertions. A matching guard in FederationProxy::handle, before bypass/auth/routing, now passes 311 / 1; combined transport passes 557 / 2. Controls are served by the healthy worker without failed-room-sync fallback. The default DB-6 integration filter passes 985 / 10 (including two opt-in PostgreSQL fallback assertions, not live database proof); a separate post-proxy live PostgreSQL run passes 180 / 2, with clean shutdown/removal and 19 stable source hashes. Fresh unit groups remain 9 / 3, 127 / 12 and 167 / 26. Independent source review approved the federation guard/socket slice; parent verified its four reviewed source/registration hashes still match the executed GREEN. A completed local/query/PostgreSQL review withheld approval for path parsers that still decode unrelated query values, unguarded direct-local join hints and a URI-only CI fixture mismatch. The latter is reproduced against isolated real PostgreSQL: both opted-in scenarios fail their missing-file prerequisite (2 assertions / 2 cases), followed by verified shutdown/removal. The fresh combined attempt exited before tests because the scratch CLI wrapper expanded an unset temporary- directory variable; its archived stale logs are not acceptance evidence. The wrapper was corrected and its actual CLI dry run succeeds, not a full test pass. The review-confirmed gaps now have genuine regressions: direct-local join hints failed 16 / 108 checks, then a checked optional parser and controlled refusal passed both dispatchers. Stronger SQLite snapshots cover event/signature/graph/membership/invite rows and both watermarks; the current join group passes 188 / 1. Authenticated ordinary, %00, %2500 and literal-NUL unrelated-query cases exposed path mixing across send, redact, state GET/PUT, report and typing (48 failed / 174). Those parsers now take the path only; GREEN passes 230 / 1, including original transaction idempotency, intended state keys, durable report targets and typing subjects. A missing-header compilation attempt is recorded as a build failure, not behavioral RED. PostgreSQL scenarios now provision an exclusive, owner-only RAII connection file from the same explicitly opted-in URI used by reopened-store checks; caller files are not selected or modified. Both URI-only and URI-plus-file isolated real runs pass 178 / 2, with owned-file cleanup and verified cluster shutdown/removal. The two fewer assertions are the removed file-presence prerequisites, not skipped admission checks. Normal DB-6 integration passes 1340 / 11 (two opt-in fallback assertions, not live PostgreSQL), including unchanged socket 557 / 2, local 259 / 3 and HTTP 100 / 1. Unit groups remain 9 / 3, 127 / 12 and 167 / 26; 21 tracked admission hashes are stable. Independent bounded repair re-review passed with no security or logic findings; all seven reviewed files matched executed GREEN. The fresh review-gap full CLI completed exit 0: 56 OK, zero failures/timeouts, fresh archived text/JSON reports, all return codes zero and 127 snapshotted files unchanged. This accepts that checkpoint, not later edits. Further decoder tracing found raw query text still reaching to-device, knock and user-metadata paths. A separate real SQLite regression reproduced 34 failed / 169 assertions, with successful no-query controls, original transaction replay, actual recipient queue delivery, intended account-data/tag keys and durable knock membership. Those three extraction boundaries now strip queries. The first GREEN build failed before tests because the to-device helper referenced a later declaration; local path-only slicing fixes that compilation error without changing decoding semantics. The corrected GREEN rerun passes 255 / 1; original query paths 230 / 1, joins 188 / 1, HTTP 100 / 1, compatibility 4 / 1 and real sockets 557 / 2 remain green. Current normal DB-6 integration passes 1595 / 12 (two gated PostgreSQL fallback assertions, not live database proof); unit groups pass 9 / 3, 127 / 12 and 167 / 26. Builds/runs exit 0 with stable admission source hashes. Current isolated PostgreSQL URI-only and URI-plus-file reruns each pass 178 / 2 with owned connection-file cleanup, cluster shutdown/removal and stable source snapshots. The scoped static scan has only the unchanged, previously disposed OPS-2 marker false positive. The next bounded review found no blocking defect in these three repairs or their test, but rejected decoder completeness: public profile GET, profile PUT, shared room action/receipt suffixes and presence still slice raw targets. Parent source inspection corroborates these gaps, including an ordinary receipt query entering the recorded event ID. A separate registered real SQLite/password-authenticated regression then reproduced 206 failed / 691 assertions, after its mandatory complete no-query control passed. All eight nonempty query/marker cases reached failures. It checks public profile fields, intended profile/presence persistence, whole state, exact receipt event IDs, durable fully-read markers and leave-last membership. Only after that RED, four extraction boundaries were changed to use the existing path-only request view. GREEN passes 733 / 1; current normal DB-6 integration passes 2328 / 13 (two gated PostgreSQL fallback assertions). Earlier query/join/HTTP/socket groups and unit 9 / 3, 127 / 12 and 167 / 26 remain green. Current isolated PostgreSQL URI-only and URI-plus-file reruns each pass 178 / 2, with verified owned-file cleanup and cluster shutdown/removal. At that checkpoint, all 22 admission sources matched executed manifests. The subsequent review found no remaining query-to-path-decoder feed in its bounded inventory, but rejected an introduced /members regression: recognized queries were still read from the now-clean room suffix. Real-dispatch conformance reproduced 50 failed / 164 assertions (the history group failed 52 / 1789). Separately extracting the original query then passed 164 / 1. A separate test exposed the pre-existing intersection of combined member filters: RED 16 / 73, then GREEN 73 / 1 after implementing the pinned specification's union semantics. Current history conformance passes 1816 / 17; DB-6 integration remains 2328 / 13, earlier focused/unit groups remain green, and both current isolated PostgreSQL modes pass 178 / 2 with verified cleanup and teardown. All 23 current admission sources match conformance/integration/unit/both PostgreSQL manifests; scoped static findings are unchanged and diff checks pass. Fresh member-repair source review passed with no security or logic findings; both reviewed files match executed GREEN. That postmembers combined checkpoint completed with 56 OK targets, all return codes zero, no failures/timeouts and 129 unchanged snapshotted files. It accepts that earlier snapshot, not the subsequent repairs. The separate device query regression required public HTTP login to populate the client device cache; setup/compiler failures are not behavioral RED. Its healthy control then passed, while all four unused-query cases reproduced 56 failed / 216 assertions across GET, PUT and password-UIA DELETE. Path-only device extraction now passes 224 / 1, including SQLite reopen, target-session revocation and preservation of the owner's device/session. Independently, backup-version DELETE reproduced 24 failed / 181 assertions with a healthy no-query control: HTTP success could leave the intended version undeleted. Extracting its version from the path now passes 181 / 1, including a surviving second version, repeat deletion and reopened durable rows. Current normal DB-6 integration passes 2733 / 15 (two gated PostgreSQL fallback assertions); history conformance remains 1816 / 17 and unit groups remain green. Isolated PostgreSQL admission/compatibility reruns each pass 178 / 2 with verified owned-file cleanup and cluster shutdown/removal; all five execution manifests match the same 23 current sources. These are not live PostgreSQL device/backup regressions, wire/TLS checks, arbitrary encoded-device-ID support or backup encryption/session-cascade coverage. Independent nondecoder source review passed with no security or logic findings. The fresh combined checkpoint passed 56 OK targets, all return codes zero and no failures/timeouts, with 130 unchanged snapshotted files; parent inspection bound the focused fixes to that run. The source freeze ended after acceptance. DB-6 remains partial and ADR-0137 proposed.
  • IMPLEMENTED (OPS-2): shared audit targets now discard the entire query string and retain only a control-safe, UTF-8-safe 255-byte path/identifier. The same policy protects durable and in-memory audit paths, actual audit.append debug output, event construction, INSERT statements and summaries. Known, unknown, encoded and duplicate parameter names cannot expose query values. Constructor RED failed 20 assertions; separate SQLite/HTTP/sink RED failed 8. Final focused unit OPS-2 passed 101 assertions / 2 cases, observability 10447 / 40 and audit 14263 / 45. Integration OPS-2 passed 44 / 2, including actual rate limiting, unavailable-runtime rejection, SQLite reopen and exact logger policy restoration. Independent review rejected the original capture fixture; a genuine restoration/unwinding RED failed 4 of 6 assertions. Exact threshold/optional-override exchanges, synchronous-writer checks and buffer redirection after configuration repair it; the scope regression passed 20 / 1. The first combined build failed the minimal logger fixture's missing encoder linkage; tests/meson.build now supplies the existing pure bounded-text source. A subsequent 56-target run preceded review correction and is not final acceptance. ADR-0136 records the intentional loss of even safe query values; historical rows, backups and logs are not rewritten. Independent re-review approved the repaired slice with no material findings. Its first final full run had 55 OK / 1 unrelated to-device TTL fixture failure. Its exact cause was not established; the previous test depended on a 1.2-second sleep to establish an expired-row precondition. The memory-store regression now seeds a definitely old positive timestamp instead of depending on wall-clock progress, proving already-expired non-delivery and purge rather than enqueue timestamp correctness or exact TTL boundaries. Production TTL logic is unchanged. Independent source-only fixture review passed with no material findings; five focused runs and the related CSAZ-10 cases passed. The resumed full verification exited 0: all 56 targets reported OK with return code 0, no failures or timeouts, and all 95 snapshotted files were unchanged. Exact completed reports are archived; the previous 55 OK / 1 failed run remains recorded rather than overwritten. Audit-inventory reconciliation is still pending: its separate execution approval timed out before the reconciliation script ran.
  • HARDENING REGRESSIONS (ISO-6 / DB-10): main's seccomp admits only descriptor-bound fchmod(0600) and private umask(0077). This permits SQLite admission and worker startup under the inherited filter without broad metadata grants; both workers still deny all fchmod/umask once their stricter profiles are installed. ADR-0129/0130. These fixes do not close the remaining ISO-6 or DB-10 acceptance review.
  • TESTS (decoder/platform batch): ISO-5 passed independently in the normal integration binary (554 assertions / 4 cases), including a separate closed-stderr run (13 assertions / 1 case). New permission/mask kernel regressions were observed RED then GREEN; the combined focused platform runs passed 378 assertions / 20 unit cases and 606 assertions / 13 integration cases, including real hardened startup. The earlier full run failed; the post-repair unified WSL rerun passed all 56 Meson targets with zero failures or timeouts. One Landlock ABI-6 unit case was skipped on the available kernel; this is a checkpoint, not closure of remaining findings.
  • TESTS: new legacy event-auth, rejected-auth fallback, discovery bounds and denied-range scenarios; updated canonical JSON and inbound PDU auth scenarios. Parent verification of these exact tags passed: unit 1717 assertions / 15 cases; conformance 1331 assertions / 4 cases. These are also covered by the successful post-repair checkpoint above; remaining remediation still needs final acceptance.

0.12.25

CSAZ-6, CSAZ-9, CSAZ-11 and CSAZ-12 of the 29 September 2026 security audit are fixed.

  • FIXED (CSAZ-11): redactions were relayed but never applied. Redacted content stayed readable on every read path and over federation, and PUT /rooms/{roomId}/redact/{eventId}/{txnId} was not routed. A redaction is now judged by the room version's "Handling redactions" rule against the power levels in its own auth_events (or the sender's domain matching the original sender's), applied by overwriting the target's stored JSON with the redacted form (the original content is deleted), served with unsigned.redacted_because, and withheld from clients, push and sync wake-ups until it applies. redacts is read and written where the room version keeps it. PUT /redact is routed: a user may redact their own events, another user's at the redact level, and a server administrator any local user's event. On the first start, redactions accepted by earlier builds are applied. ADR-0125.
  • FIXED (CSAZ-6): a ban or kick sent through PUT /state/m.room.member did not take effect. It changed room state, but the user stayed in /joined_members and /joined_rooms and could rejoin, because only the membership APIs updated the membership tables. persist_composed_event now projects every locally composed m.room.member event that becomes current state (memberships, LocalRoom.members, device-list share changes, invite metadata); the membership APIs, join and createRoom no longer do it themselves. A banned user still reads the history they could see before the ban, and nothing after it, as the spec's history visibility rules require. ADR-0126.
  • FIXED (CSAZ-9): locally created events were not held to the spec's size limits. An event whose signed canonical JSON is over 65536 bytes, or whose type or state_key is over 255 bytes, is refused with 400 M_TOO_LARGE on /send, /state, the membership endpoints and createRoom, and nothing is stored (createRoom checks client content before storing the room). OperationResult::errcode and LocalHttpResponse::errcode carry a specific errcode to the client; a 400 used to become M_UNKNOWN.
  • FIXED (CSAZ-12): reports, upgrades, directory visibility and aliases were not gated.
  • POST /rooms/{roomId}/report/{eventId} answers 404 M_NOT_FOUND unless the reporter is joined and the event is in the room and visible to them; the path is percent-decoded; a repeat report of the same event by the same reporter writes no second audit row.
  • POST /rooms/{roomId}/upgrade answers 403 M_FORBIDDEN before creating anything unless the user may send m.room.tombstone, and fails if the tombstone is refused instead of reporting success.
  • PUT /directory/list/room/{roomId} needs the power to send m.room.canonical_alias, or a server administrator.
  • PUT /directory/room/{roomAlias} validates the alias grammar and this server's domain (400 M_INVALID_PARAM) and needs canonical-alias power or an administrator.
  • NEW: DELETE /directory/room/{roomAlias}, for the alias's creator, a joined user with canonical-alias power, or an administrator; 404 M_NOT_FOUND when unmapped. ADR-0127.
  • MIGRATION 021: room_aliases.creator_user_id (empty for existing aliases, which then need canonical-alias power or an administrator to delete). database::delete_room_alias.
  • NEW: events::required_state_event_power (the auth rule's required level for a state event, now shared with the client API gates), homeserver::may_send_state_event, auth::room_alias_is_valid, events::max_event_type_length_bytes.
  • TESTS: [csaz-6] tests/conformance/test_membership_projection_conformance.cpp, [csaz-9] tests/conformance/test_event_size_limits_conformance.cpp, [csaz-12] tests/conformance/test_room_admin_gates_conformance.cpp, the alias validator in tests/unit/test_auth.cpp and the alias creator and deletion in tests/unit/test_database_persistence.cpp; CSAZ-11 in tests/conformance/test_redaction_application_conformance.cpp, tests/conformance/test_redaction_federation_conformance.cpp and tests/unit/test_redaction_validity.cpp. The two DELETE /directory/room "implementation gap" conformance scenarios now expect the spec's 404 M_NOT_FOUND; the trust-and-safety unit scenario and the AUTH-1 safety-report listing scenario (tests/integration/test_persistent_homeserver_flow.cpp) report real events; the migration-count assertions count 21 steps.
  • FIXED: ConnectionParker::parked() could still count a connection its peer had already seen closed. The poll loop closed expired, hung-up and invalid connections first and decremented parked_count after the whole pass, so a peer woken by the close could read the old count (NetBSD CI: test_net_connection_parker.cpp:393). The count is now decremented before those connections are closed; closing stays outside the lock.
  • TESTS: refused membership actions (/invite by a non-member, /ban and /unban without power, /forget while joined, /knock on a non-knock room) answer the spec's 403 or 400 and change nothing; malformed redactions name no target and never apply (tests/unit/test_redaction_validity.cpp). may_send_state_event refuses when the room's create event is missing or has no readable content, and PUT /redact with a body that is not a JSON object answers 400 M_BAD_JSON and redacts nothing.
  • BUILD: merovingian-auth links merovingian-canonicaljson (auth::room_alias_is_valid and oidc_discovery.cpp use it); src/meson.build configures canonicaljson first. The fuzz config-parser target, which links auth without canonicaljson, failed to link.
  • TESTS: refusal paths of the CSAZ-12 gates (malformed alias, unknown room, 409 conflict, administrator mapping, invalid visibility, unsupported upgrade version, report in an unknown or different room), every createRoom size pre-check field, and tests/unit/test_room_power.cpp ([power]).
  • Docs: the audit report's status (14 lows fixed), docs/event-engine.md, docs/threat-model.md, docs/trust-safety.md, docs/database-persistence.md, docs/security-coding-rules.md, docs/matrix-v1.19-client-server-api.md, docs/todos/capability-gaps.md, migrations/AGENTS.md, src/homeserver/AGENTS.md.

0.12.24

  • FIXED: a waiting /sync was counted against the rate limit again every time it was run again. A long-poll is run once more after each wake-up and when its timeout expires (on the sync pool, in the no-pool fallback and in dispatch_local_http_request), and each run went through allow(). A client allowed one more /sync was refused with 429 at the moment its waiting sync was answered. The re-runs now pass rate_limit_admitted (rerun_waiting_sync), as the media fetch pool's re-run does (0.12.21), so each /sync is counted once, when it first arrives.
  • TESTS: [http-4][sync][rate-limit] in tests/integration/test_http_server_listener_flow.cpp: under a sync limit of one per minute, every re-run path is answered 200: on the sync pool, on a request worker with no sync pool, and in dispatch_local_http_request, each both at its timeout and when woken by a new room; the client's next /sync in the window is still refused. Each path fails with 429 when the re-runs are counted again.
  • NEW: sync::SyncNotifier::waiting(), the number of callers blocked in wait_for_change, so a test can tell when a long-poll has started waiting ([sync][notifier] scenario).
  • TESTS: the listener tests' response reader (receive_response in tests/integration/test_http_server_listener_flow.cpp) gives up after 30 s instead of blocking forever. A connection the server neither answered nor closed made OpenBSD CI kill the whole integration binary at its 900 s limit, mid-scenario; a stall now fails only that scenario.
  • CI: scripts/build-bsd.sh runs meson test with --timeout-multiplier 2 unless MESON_TEST_TIMEOUT_MULTIPLIER says otherwise. The BSD CI VMs' speed varies by 20-40% between runs, and the unit, conformance and integration binaries each hit their meson limits on OpenBSD runs where nothing hung. Test: tests/tooling/test_dependency_wraps.py.
  • Docs: docs/http-transport.md (sync re-runs; the default per-account and per-device wait caps are 4 and 2, not 8 and 4 as it said).

0.12.23

Every AUTH finding of the 29 September 2026 security audit is now fixed.

  • FIXED (AUTH-2): a stranger could hold any account in login lockout. Five failed logins refused every login for that account for 15 minutes, correct passwords included, and blocked password change, deactivation and device deletion too. Failures are now counted per (account, client source) and per account: security.login_throttle.max_failures_per_source (5) refuses only that source, max_failures_per_account (50) refuses every source, within security.login_throttle.window (15m). A correct login clears only its source's counter. User-interactive-auth password checks have their own (account, device) counter, which login failures never touch, so the owner's sessions keep working. docs/auth-identity.md no longer claims a real login clears the lockout. ADR-0122 (supersedes ADR-0066, refines ADR-0032).
  • FIXED (AUTH-10): the failed-login map was unbounded and scanned on every failure. The new auth::FailureWindowTable stores a fixed-size BLAKE2b digest per key, holds at most 100,000 entries, and expires entries from a list ordered by window start.
  • FIXED (AUTH-5): 3PID ownership was never validated. requestToken without an identity server created a session already marked validated and sent nothing, so a user could bind someone else's email address. Ownership is now proven only by a trusted identity server: requestToken without one answers 400 M_THREEPID_MEDIUM_NOT_SUPPORTED; delegated sessions start unvalidated and keep the identity server's URL and access token in memory only; /account/3pid/add, /bind and POST /account/3pid confirm with the identity server (getValidated3pid) for the same medium and address, otherwise 400 M_SESSION_NOT_VALIDATED. A consumed session is erased so it cannot be replayed. A bind naming an untrusted identity server is refused instead of being recorded as bound. ADR-0123.
  • FIXED (AUTH-7): other services' exclusive namespaces were ignored. An application service can no longer register (400 M_EXCLUSIVE), log in as or assert (403 M_EXCLUSIVE) a user in another service's exclusive namespace. An out-of-namespace /register now answers 400 rather than 403, as the spec's response table gives.
  • FIXED (AUTH-12): an application service could act as a deactivated or non-existent user. Asserting a user that is not registered or is deactivated answers 403 M_FORBIDDEN; bridges must register a puppet before acting as it. /register and /login are exempt. ADR-0124.
  • FIXED (AUTH-8): identity assertion applied to account management. A masqueraded request to any /account/* endpoint except GET /account/whoami answers 403 M_FORBIDDEN.
  • FIXED (AUTH-9, remainder): log field values were unbounded. Every structured log field value is capped at 2048 emitted bytes on a character boundary, with a ...[truncated N bytes] marker; redaction runs first.
  • FIXED (AUTH-4, remainder): Argon2id in /register, password change and UIA ran under the runtime mutex with no admission limit. Each now takes a slot in the shared Argon2id budget (429 M_LIMIT_EXCEEDED with retry_after_ms when none is free, never counted as a failed attempt), hashes with the mutex released, and re-checks the username, session and stored hash once it is re-taken. /register 429s now carry retry_after_ms.
  • FIXED (AUTH-1, remainder): login.rejected and login.throttled audit rows are rate-capped like other unauthenticated rejections.
  • CHANGED: the example config enables remote media fetching. config/merovingian.conf.example sets security.media.remote_fetch_enabled=true, so a server set up from it can download attachments sent from other servers. The built-in default stays false: a config without the line refuses every remote download with 404 and sends nothing.
  • TESTS: tests/unit/test_login_throttle_http.cpp, test_failure_window_table.cpp, test_config_login_throttle.cpp ([login-throttle]); tests/integration/test_identity_service_flow.cpp and tests/unit/test_identity_client.cpp ([auth-5]) with a new MockIdentityServer in tests/support/tls_mock_server.hpp; tests/conformance/test_appservice_assertion_conformance.cpp and tests/unit/test_appservice_masquerade_token.cpp ([auth-7], [auth-8], [auth-12]); tests/unit/test_security_audit_log_controls.cpp and test_audit_flood.cpp ([auth-9]); tests/unit/test_security_audit_auth_4_admission.cpp and tests/integration/test_security_audit_auth_4_admission_flow.cpp ([auth-4]); tests/tooling/test_example_config.py. Each new test failed against the code before its fix.
  • Docs: ADR-0122, ADR-0123, ADR-0124; audit report remediation status; docs/auth-identity.md, user manual, example config, observability, threat model, security coding rules, and the src/auth, src/appservice, src/identity and src/observability AGENTS.md files.

0.12.21

  • FIXED: a federated user's attachment could not be downloaded in Element (ADR-0121). A remote media download or thumbnail held a main request-pool thread for the whole fetch, so ADR-0079 allowed one per client address and refused the rest with 429 M_LIMIT_EXCEEDED. Element Web downloads an image and its thumbnail at once, and every remote image on screen, and does not retry a media 429: the image stayed broken ([429] too many concurrent remote media fetches). Remote media that must be fetched now runs on a dedicated media fetch pool. The main pool still does the quick checks (remote_fetch_enabled, allow_remote, policy, the remote media cache) and on a cache miss hands the request to the pool, as a waiting /sync is handed to the sync pool. A peer that never answers can occupy the media pool but no main-pool worker. Directory lookups (publicRooms?server=, remote aliases) keep the ADR-0079 budget.
  • NEW: server.http.media_fetch_threads (16), server.http.media_fetch_max_in_flight (64) and server.http.media_fetch_max_per_client (8). Workers of the media fetch pool, fetches running or queued, and of those for one client (the rate-limit key, trusted_proxies honoured). Over either cap a request gets 429 M_LIMIT_EXCEEDED with retry_after_ms 1000 at once. Restart required.
  • FIXED: concurrent requests for the same remote file fetched it twice. The second admission displaced the first copy from the remote media cache and deleted it while the first request could still be reading it. Fetches of one remote file are now coalesced (RemoteMediaFetchCoalescer): a second request waits for the first, with the runtime mutex released and within its own deadline, and serves the stored copy.
  • FIXED: a request run a second time by another pool is no longer counted twice by the rate limiter when it is a remote media request (ClientServerDispatchOptions::rate_limit_admitted). The sync pool's re-runs of a long-poll are unchanged.
  • TESTS: [media-fetch-pool]: tests/integration/test_remote_media_fetch_pool_flow.cpp (a stalled origin does not hold a request thread; three remote files from one client all served; a download and thumbnail of one file reach the origin once; per-client and stopped-pool backpressure; one rate-limit count per request) and tests/unit/test_remote_media_fetch.cpp (coalescer, fetch scope, admission caps); config scenarios in tests/unit/test_config_operational_limits.cpp.
  • TESTS: a remote media download answered by the media pool keeps its connection alive and hands it back to the dispatcher, which serves the next request on it ([media-fetch-pool][keep-alive], through a real HttpConnectionDispatcher and listener).
  • TESTS: the mock HTTPS server's certificate (tests/support/tls_mock_server.hpp) is backdated an hour. Its notBefore was the moment it was issued, so a wall clock stepping back a moment (WSL does) made verification fail as "not yet valid" in whichever scenario was unlucky.
  • TESTS: the logger thread-count scenario ([logger][iso1]) waits for its calibration helper thread to leave /proc/self/task before counting. A joined thread can stay listed for a moment after join() returns, which charged it to the logger (Ubuntu CI: before=1 after_use=2).
  • FIXED: the Catch2 BDD gate timed out on OpenBSD CI. scripts/check-catch2-bdd-tests.sh ran up to six grep processes per unit test file (~1,300 per run) and exceeded meson's 30 s test timeout on the OpenBSD VM. It now reads every file once in a single awk process, with the same rules and messages (3.7 s to 1.0 s on WSL); the comment-marker rule no longer relies on the GNU-only \| alternation. New tests/tooling/test_check_catch2_bdd.py pins the behaviour.
  • Docs: ADR-0121 (ADR-0079 superseded for remote media), docs/http-transport.md "Remote media fetch pool", media repository, threat model, user manual, example config, security coding rules, src/homeserver/AGENTS.md.

0.12.20

  • TESTS: the PostgreSQL-only media scenarios ([postgresql][media]) read blob bytes after a reopen through read_media_blob, since hydration no longer loads them, and the released-blob scenario checks the bytes column directly, because read_media_blob skips unreferenced blobs and would pass whether or not the bytes were cleared.
  • CHANGED: media bytes live in the database and are read on demand (ADR-0119). Every stored media file used to be loaded into memory at startup and served from there, so memory grew with stored media. Hydration now reads blob metadata only; downloads, thumbnails, remote-cache hits and the federation media endpoint read the bytes per request with the runtime mutex released, then re-check that the media may still be served, so media removed or quarantined during the read is refused. LocalMediaBlob has no bytes member. Uploads write bytes straight from the request; a deduplicated upload updates only the reference count instead of rewriting the file. max_total_size now bounds storage, not memory; the defaults are unchanged.
  • FIXED: the federation media download read the media repository without the runtime mutex. Its lookups rebuild the repository's indices, so it raced with uploads and moderation. It now takes the mutex for the lookups (download_local_media_for_federation).
  • FIXED: remote media was never written to the database. It is now stored like an upload, and the remote media cache is durable (migration 020 adds local_media_id and fetched_at_ms to the previously unused remote_media table). Admission is planned, committed in one transaction (commit_remote_media_admission) and then applied, so each stored remote copy stays reachable from one cache entry: a re-fetch after the TTL replaces the old copy, eviction at remote_media_cache_max_entries deletes it, the cache survives restarts, and quarantined or removed remote media is answered 451/404 from the cache instead of being fetched and stored again on every request. remote_media_cache_max_entries = 0 now keeps at most one remote copy.
  • CHANGED: thumbnails are decoded with the runtime mutex released (low audit finding MED-4, decode part); the per-user thumbnail cap from that finding is not done.
  • TESTS: new tests/integration/test_media_on_demand_flow.cpp and tests/integration/test_remote_media_cache_flow.cpp ([media-on-demand]): bytes served from the database after they change underneath the server, a deduplicated upload writing no bytes, federation downloads alongside uploads (for ThreadSanitizer), the remote cache across a restart, TTL replacement, quarantine caching and eviction. [out-4] unit scenarios rewritten for the plan/apply admission; moderation-during-read guard tested. Tests that asserted bytes in memory now assert them through the database read path, or directly against the SQLite file where removal must clear the durable bytes.
  • CHANGED: media quota defaults sized for on-disk storage. Now that media bytes live in the database, the defaults are max_total_size=250GiB, max_size_per_user=10GiB and max_records=1000000 (chosen by the project owner; record metadata is still held in memory, roughly 400 MB at the cap). remote_media_cache_max_entries defaults to 50,000.
  • NEW: security.media.remote_media_cache_max_size (default 25GiB, ADR-0120). Cached remote media has its own byte budget: least recently used remote files are deleted to stay within it, and a single file larger than it is refused with 507. Remote media no longer counts toward max_total_size or max_records, so caching other servers' media cannot make a local upload fail. A local upload of bytes already stored only for remote media is charged to the local total.
  • FIXED (OPS-3): malformed media size limits are rejected. max_total_size, max_size_per_user and remote_media_cache_max_size must be empty (no limit) or parse; a value such as 10G used to parse as 0, which meant no limit. The effective limits are logged at startup, the reload plan reports changes to them, and src/config/AGENTS.md names the accepted suffixes (B, KiB, MiB, GiB).
  • TESTS: [med-6] pins the new defaults and checks that remote media is charged only to its own budget and evicted by bytes; [ops-3] checks that malformed sizes are rejected and empty ones accepted.
  • Version bumped to 0.12.20 for the media-on-demand branch (stacked on 0.12.19).

0.12.19

  • FIXED: BSD builds failed on an unused Landlock constant. k_scope_signal (ISO-2) was declared on every platform but used only where the Linux ruleset is built; under -Werror the FreeBSD, NetBSD and OpenBSD package builds failed. It is now declared in the Linux-only block that uses it.
  • TESTS: the logger thread-count scenario ([logger][iso1]) runs in a forked child that starts with one thread, and first proves thread counting works by starting a helper thread. In the shared test process it skipped unless another test's thread happened to be lingering, which is exactly when the count was flaky (ASan CI: 3 == 4).
  • TESTS: two PostgreSQL-only scenarios ([postgresql][media]) still expected the in-memory blob mirror to carry bytes after a write, which MED-6 removed; they failed only in the coverage and postgres-integration CI jobs. They now assert the mirror records the size with no bytes, and check the bytes after a reopen reads them from PostgreSQL.

  • FIXED: SQLite room snapshot no longer binds one parameter per event (DB-2). Federation worker room-snapshot relation queries now scope reads by room_id via a JOIN instead of building an IN (...) parameter list, so large rooms avoid the 128-parameter limit.

  • Close the remaining six 2026-09-29 security audit medium findings (AUTH-3, FED-6, MED-5, DB-5, CSAZ-10 and DB-2). docs/security-audit-report-2026-09-29.md is updated to list them as fixed.
  • Update migration-count and last-migration assertions in unit and integration tests for migration 019 (to_device_queue_age_bound).
  • TESTS: isolate the persistent-homeserver integration scenarios on per-test SQLite files instead of the shared in-memory backend, removing an ordering dependency that surfaced after migration 019.
  • FIXED (ISO-2 regression): the federation worker's seccomp filter allowed every syscall after tgkill. The tgkill argument check added for ISO-2 skipped one instruction too many when the syscall was not tgkill, landing on the next entry's RET ALLOW. Every syscall the allow list had not matched before tgkill, including kill, prlimit64, setrlimit and execve, was therefore allowed. A mismatched tgid also fell through with the accumulator holding args[0]. The block now jumps over exactly its own instructions and returns the default action on a mismatched tgid.
  • FIXED (OUT-4): the remote media cache is consulted before the network. The cache was looked up only inside media::fetch_remote_media, which runs after discovery and the outbound request, so every client request for a remote mxc:// URI still reached the origin. fetch_remote_media_live now serves a cached record before taking an outbound budget slot. Also fixed: an expired entry was never removed and, being found first, made every later lookup for that key miss; remote_media_cache_max_entries = 0, documented as disabling the cache, removed its bound instead; a removed or quarantined record's entry is now erased on lookup. New media::find_cached_remote_media. ADR-0114 records where the lookup must sit.
  • TESTS: [out-4] integration scenario counts the requests a real HTTPS origin receives for two downloads of the same remote image (one, not two); the [out-4] unit scenarios now drive the pre-network lookup (hit, miss by key, expiry and re-admission, zero cap, removed record, LRU eviction) instead of the post-fetch admission step.
  • FIXED (MED-5): only the default port is dropped when deciding whether media is local. The canonicalisation used federation::strip_server_port, which drops any port, so a request for example.org:8449 (a different server name) was served this server's media. It now lowercases and drops a trailing :8448 only. ADR-0115 corrected: it claimed both behaviours, and claimed a discovery-to-self check the code does not have.
  • CHANGED (MED-6): media quota defaults sized to memory. The defaults introduced earlier on this branch (10MiB per user) refused a single upload at the 50MiB max_upload_size, and charged all remote media from an origin to one @remote-media:<origin> owner, cutting the origin off after 10 MiB. Defaults are now max_total_size=2GiB, max_size_per_user=256MiB, max_records=100000. All media is held in memory, so the total is a memory budget. Remote media is exempt from the per-user quota and still counts toward the total and record caps. ADR-0113 updated; docs/user-manual.md documents the defaults and the two remote-media cache keys, and its media table, which a paragraph had split in two, renders as one table again.
  • TESTS: [med-6] scenarios pin the defaults and their relationship (max_size_per_user >= max_upload_size, total ≥ per user), check that a user past the default quota gets 507 while another user is unaffected, and that remote media is exempt from the per-user quota but not from the server total.
  • FIXED (DB-5): a media upload that could not be stored is refused instead of answered with an mxc:// URI. upload_local_media discarded the results of the media-row and blob writes, so a database failure left the client holding a URI whose media vanished at the next restart, and the two rows were written in separate transactions. New database::commit_local_media_upload writes both in one transaction; on failure the upload answers 500 and new media::rollback_local_media_upload removes the record and releases its blob reference in memory, including for a deduplicated upload. The unused persist_blob_for_media helper is removed. docs/database-persistence.md lists the revocation and upload write rules.
  • TESTS: [db-5] media scenario fails the blob write of a new upload and the record write of a deduplicated one through the memory backend's statement-failure injection, and checks the response, memory and store, a successful retry, and that the earlier upload is still served.
  • FIXED (CSAZ-10): to-device rows from before migration 019 were dropped on the next enqueue. Migration 019 gives existing rows created_at_ms = 0. The enqueue path treated that as expired and deleted the row as soon as another message for the same device arrived, while the drain path treated it as fresh. Both now treat an unknown age as not expired; the per-device count cap still bounds such rows.
  • DOCS (CRY-2, ISO-2): federation.worker.ipc_max_dispatch_queue_count and ipc_max_dispatch_queue_bytes are documented in docs/user-manual.md and the example config; docs/hardening.md and docs/threat-model.md describe the dispatch-queue bound and the worker signal/limit restriction, with the shared-uid residual; ADR-0112 records how an argument-checked entry must be laid out in the generated BPF program and that it needs a real-kernel test; ADR-0065 points to ADR-0111; ADR-0111 and ADR-0112 dates corrected.
  • FIXED (ISO-2 regression): a hardened federation worker was killed at startup. The ISO-2 change removed setrlimit and prlimit64 from the worker filter, but the worker's event loop calls start_runtime(), which re-applied the main-process hardening profile and its setrlimit(RLIMIT_CORE) after the worker filter was installed: SIGSYS, and the supervisor restarted the worker in a loop, taking federation down. The allow-all bug above hid this. New RuntimeStartOptions::process_hardening_applied_by_caller, set by the worker when federation.worker.apply_hardening is on, skips the main profile. prlimit64 is allowed again only to read the worker's own limits (pid 0 or its own pid, NULL new limit): glibc's getrlimit() is prlimit64, and thread creation uses it. The BPF builder now derives each argument-checked entry's skip distance from the block's size instead of a hand-counted constant. src/federation_worker/AGENTS.md records the constraint.
  • TESTS: [iso2] also checks that prlimit64 reading the process's own limits and getrlimit() are allowed while setting a limit or reading the parent's is denied. The [iso-2] list test expects prlimit64 in the worker list, as it already did for tgkill, since both are argument-checked. [federation-worker][seccomp] passes again.
  • FIXED (CSAZ-10): per-user caps on E2EE key and filter uploads. New server.client_api.max_one_time_keys_per_device (1000), max_key_signatures_per_user (10000) and max_filters_per_user (1000). An upload that would cross one is refused with 400 M_TOO_LARGE and stores nothing. An identical filter (canonical JSON) returns the existing filter_id instead of a new one, and new filters are stored canonically. ADR-0118.
  • FIXED (CSAZ-10): one fallback key per algorithm per device. The spec says the server "will only persist one key per algorithm"; each fallback-key upload with a new key ID added a row. A new fallback key now replaces the previous one of its algorithm in one transaction.
  • CHANGED (CSAZ-10): /keys/query reads key signatures through an index by target user (database::key_signatures_for_target) instead of scanning every signature upload; a stale index falls back to a scan.
  • TESTS: new [csaz-10] unit files test_fallback_key_single.cpp, test_otk_cap.cpp, test_key_signature_cap.cpp, test_filter_cap.cpp, test_key_signature_index.cpp, shared fixture tests/support/e2ee_caps_support.hpp, and config scenarios in test_config_operational_limits.cpp.
  • DOCS: the audit report's "Remediation status" records all 31 medium findings as fixed, with what was corrected in the first-round fixes and the residual notes (ISO-2 shared uid, MED-5 no discovery-to-self check); its earlier claim of such a check is removed. docs/todos/capability-gaps.md closes the medium remediation section, whose table had marked AUTH-3 complete.
  • TESTS: [session_restart] waits until the wall clock, which token expiry is measured against, is past the token's expiry instead of sleeping a fixed 1.5 s. WSL2 can step the wall clock back by over a second; a full-suite run and 1 in 20 isolated runs failed because the sleep ended before the expiry. The assertion is unchanged.
  • TESTS: the worker-seccomp TSYNC scenario no longer skips. The skip added with the regression above masked it: the pre-existing thread's execve succeeded because the filter allowed it, not because of a kernel quirk. New real-kernel scenario [iso2] installs the worker filter in a forked child and checks that kill and tgkill aimed at the parent and prlimit64 are denied, tgkill on its own thread group is allowed, and syscalls listed after tgkill are still matched correctly.

0.12.18

  • Remove the retired September 2026 audit report and its documentation navigation entry; retain the later 29 September report and remediation ADRs.
  • Raise normal client and federation traffic budgets and sync/page defaults; expose HTTP parsing, sync admission, API scan/page/session, recovery, cache, outbound queue, push and application-service budgets as validated deployment settings.
  • Raise per-address open-connection admission from 64 to 256 for clients sharing an address, while retaining global dispatcher and worker bounds.
  • Raise /state_ids state/auth list caps from 1,000 to 65,536 each, while bounding each incoming PDU's cumulative recovery work by a shared call budget and deadline. Recovery still depends on remote availability and local event history.
  • Make per-user notification retention configurable and raise its default from 200 to 1,000 entries, independently of push delivery.
  • Make state-resolution group, key, mainline and auth-walk budgets configurable; allow 65,536 entries per group and enforce an aggregate 131,072-entry default before resolving forks.
  • Replace the conflicted-event duplicate scan with hash indexing while preserving first-occurrence order, avoiding quadratic collection work at larger resolver capacities.
  • Honor the configured federation transaction byte cap at the HTTP layer and size worker IPC frames for the configured request and response caps.
  • Require restart for startup-snapshotted federation/client limits rather than claiming that SIGHUP applies them.
  • FIXED: application-service sender_localpart users are created from the loaded registry at startup (AUTH-3). The startup loop that ensures each registered appservice has a real sender_localpart user was iterating runtime.appservices before the registry had been moved from the temporary loaded.registry, so no sender users were ever created. It now walks the loaded registry directly.
  • FIXED: federation make_join/make_leave/make_knock reject a {userId} that is not on the origin server (FED-6). handle_make_membership now compares the path parameter's domain with the verified requesting origin; a mismatch returns 400 M_INVALID_PARAM.
  • FIXED: media downloads canonicalise the local server name before routing (MED-5). Host case and the default federation port (:8448) are now normalised so variants such as EXAMPLE.ORG:8448 are treated as local media rather than fetched remotely.
  • FIXED: token revocation paths fail closed on persistence failures (DB-5). revoke_* helpers return a row count, so zero is ambiguous between "no rows" and "the backend write failed". Every auth-service revocation path now verifies the persistent-store state after the write and returns 500 if any targeted token is still unrevoked. A test-only memory-backend seam lets unit tests force a named revocation statement to fail. ADR-0116.
  • FIXED: per-recipient to-device queues are bounded by count and age (CSAZ-10). to_device_messages gains a created_at_ms column, migration 019, and configurable server.client_api.max_to_device_messages_per_user_device / to_device_message_ttl_seconds. Enqueue evicts expired rows then the oldest rows until the new message fits; drain also purges TTL-expired rows. This prevents non-existent devices from accumulating unbounded queue state. ADR-0117.
  • FIXED: federation worker room-scoped reads trust main's verified identity. When main forwards an inbound request to the worker over authenticated IPC, the X-Matrix signature has already been verified and server/discovery/trust policy applied. The worker now synthesizes a minimal remote record from the verified origin/key_id instead of re-resolving the peer, allowing room-scoped reads to be served (and fallback-tested) without a redundant network resolution step. Direct, unverified requests keep the full fail-closed resolution path. ADR-0110.
  • TEST-ONLY: handle_local_http_request supports PUT /_matrix/client/v3/rooms/{roomId}/state/{eventType}[/{stateKey}]. This lets integration tests seed room state events (e.g., m.room.server_acl and m.room.history_visibility) through the local-router seam used by federation-worker tests.
  • TESTS: new tests/integration/test_security_audit_worker_snapshot_flow.cpp verifies that a failed worker room reload falls back to main's authoritative state, so a stale allow-ACL cannot outlive a fresh deny-ACL. tests/integration/test_security_audit_federated_to_device_flow.cpp is now registered and passing.
  • FIXED: inbound federation to-device EDUs with an empty message type are no longer reported as accepted. enqueue_direct_to_device_messages now treats an empty per-message type as an invalid envelope, and the EDU sink returns rejected_invalid instead of accepted when nothing can be stored. Existing tests that delivered to a local user without a matching device now register the device so the end-to-end delivery assertion actually exercises the store path.
  • Tighten secure defaults for configurable operational limits. sliding_sync_max_timeline_limit defaults to 100, sliding_sync_max_room_subscriptions and sliding_sync_max_required_state_entries to 256; HTTP sync admission defaults to 4 per user / 2 per device; federation pending-join queues default to 32 PDUs and 512 KiB. These match the security-audit integration tests and limit amplification surfaces out of the box.
  • TESTS: the draft security-audit integration tests are moved from build-wsl/drafts/ to tests/integration/, registered in tests/meson.build, and passing; new tests/unit/test_config_operational_limits.cpp, test_configurable_client_limits.cpp, test_configurable_federation_limits.cpp and test_configurable_http_limits.cpp are added to the suite.
  • FIXED: reloadable smoke test uses a genuinely reloadable key. The server-plan-config-reload-reloadable-smoke-test in tests/smoke/meson.build changed security.federation.remote_timeout, but federation fields now correctly report restart_required. It now changes security.media.local_upload_policy, which is reloadable, so the smoke test once again verifies the reload path instead of a restart-required path.
  • FIXED: inbound federated to-device messages addressed to device * are delivered again. The known-device filter (ADR-0107) matched * literally against registered devices, so every wildcard m.direct_to_device message (for example m.room_key_request and its cancellations) was silently dropped while the transaction returned 200. The Server-Server API defines * as "all known devices for the user"; it is now expanded into one queued row per registered device of the target user, as the client-server sendToDevice path does, and each expanded row counts against the per-EDU delivery cap.
  • FIXED: E2EE key routes keep their rate limit and their own bucket. Route normalization (ADR-0092) sent POST /keys/claim and POST /keys/upload to the shared {unmatched} fallback, and the built-in refinements were matched against the normalized route only. Both routes therefore lost their 120/min cap (falling to the 600/min generic default, which made one-time-key draining easier) and shared one bucket with every unknown path. Built-in per-IP and per-user refinements now also match the original path, and the matcher gains templates for every implemented route that was falling back: keys/claim, keys/upload, room_keys/version/{version}, room_keys/keys/{roomId}[/{sessionId}], rooms/{roomId}/unban, user/{userId}/rooms/{roomId}/tags[/{tag}], presence/{userId}/status, login/sso/redirect/{idpId} and the two im.nheko.summary routes.
  • FIXED: login no longer dereferences a missing dummy password hash. The Argon2id snapshot in login_local_user dereferenced dummy_password_hash() unconditionally for an unknown user, although it returns null when the dummy hash could not be created; GCC 16 rejected this with -Werror=null-dereference (Fedora RPM build). The snapshot is now an std::optional, and a login with nothing to verify against is refused as invalid credentials, as before the snapshot was introduced.
  • FIXED: tests build on the libc++ of FreeBSD and OpenBSD again. Four new scenarios used std::jthread, which that libc++ does not provide; they now use tests/support/joining_threads.hpp, which gains an explicit join() for scenarios that assert after the thread has finished.
  • FIXED: merovingian-http declares its link to merovingian-core. outbound_client.cpp now uses core::SocketHandle, so targets linking http_lib without core_lib (the fuzz-srv-record fuzz target) failed to link.
  • TESTS: the %2e%2e path-preservation check in test_security_audit_outbound_flow.cpp compares the request line case-insensitively. libcurl 8.20 and later send percent-encodings with upper-case hex digits (equivalent under RFC 3986), which failed the exact-byte comparison on openSUSE, NetBSD and the coverage job's bundled curl; the dot segment is still required to reach the peer unresolved.

  • FIXED: IPC dispatch queue is bounded (CRY-2). IpcChannel::dispatch_queue_ previously grew without limit on a fast producer, allowing memory exhaustion. The queue now drops the oldest stale frame once a configurable cap is reached and counts the drops. ADR-0111.

  • FIXED: federation worker syscall profile is tighter (ISO-2). The worker seccomp allow-list no longer permits kill, tkill, setrlimit, or prlimit64; tgkill remains allowed only when its thread-group argument matches the worker's own TGID. Landlock rulesets now request ABI-6 signal scoping on capable kernels. ADR-0112.
  • FIXED: media quotas default to operational bounds and blobs are held once in memory (MED-6). security.media.max_total_size, max_size_per_user, and max_records now default to 1GiB, 10MiB, and 100000 instead of zero. After hydrating the runtime repository from PersistentStore, the persistent copy of blob bytes is cleared so only one in-memory copy is held. ADR-0113.
  • FIXED: remote media downloads are cached by origin and media ID (OUT-4). Fetched remote media is keyed by (origin_server, media_id) with a configurable TTL and LRU eviction, so repeated requests serve the already-stored local record instead of re-fetching and re-storing. ADR-0114.

0.12.17

  • Pages integration and release version. Preserve the merged 0.12.16 documentation site and update all live binary/package versions to 0.12.17. Four audit ADR links now use the actual published anchors. The merged full suite passes 55 groups with no failures or timeouts; strict Pages build, 8 package checks and 20 documentation-tooling checks pass.

  • FIXED: a password change during Argon2id verification no longer issues a session. login_local_user used the password hash snapshot taken before releasing runtime.mutex and ignored any concurrent password change until after the session was minted. It now re-finds the user and re-checks the stored hash after re-acquiring the lock, so a password invalidated mid-login fails with 403 M_FORBIDDEN. ADR-0104.

  • FIXED: sendToDevice no longer creates queue state for unknown local recipients. The client-server PUT /sendToDevice endpoint previously enqueued a to_device_messages row and advanced the sync stream ID for any syntactically valid local (user_id, device_id), including non-existent users and devices that did not belong to the named user. It now silently discards those deliveries while still fanning out wildcard * to the target user's registered devices. ADR-0105.

  • FIXED: MSC4186 to_device pagination no longer acknowledges undelivered rows. The sliding-sync to_device extension reported the global sync watermark as next_batch, causing the next request to delete every earlier device-targeted row even if the previous response was truncated or lost. The continuation token is now the stream ID of the last message actually included in the response, and an empty response keeps the previous position. ADR-0106.

  • FIXED: inbound federation to-device EDUs no longer queue unknown local devices. m.direct_to_device EDUs in /_matrix/federation/v1/send/{txnId} previously advanced the sync stream ID for every syntactically valid target device, even when the device did not exist or did not belong to the named local user. The inbound path now silently discards those deliveries. ADR-0107.

  • FIXED: federation worker room-sync failures fall back to main. The federation worker's PersistentStore is a startup snapshot, and room_sync notifications were fire-and-forget: a reload failure left the worker serving stale room state. Main now assigns a per-room generation to each room_sync notification, the worker reports ok/failed back via a new room_sync_result IPC frame, and FederationProxy falls back to the authoritative main-process state when the last observed sync failed. ADR-0108.

  • Log records cannot be forged by control characters (AUTH-9). A JSON \n in a login identifier.user or device_id wrote an extra physical log line, and ESC or a C1 CSI reached the operator's terminal raw. SingleLog::make_log_line now passes the module and message of every console and file line through the new escape_log_controls: \n, \r, \t become those escapes, other C0 controls and DEL become \xHH, UTF-8 C1 controls become \u00HH, and all other bytes (including UTF-8) pass through. ADR-0102.

  • FIXED: a sensitive last field joined the next log record onto its line. redact_log_message split tokens on spaces only, so when a line ended in a sensitive key=value its replacement also discarded the record's \n. It now treats every whitespace character as a token boundary and keeps it.

  • TESTS: new tests/unit/test_security_audit_log_controls.cpp asserts at the real synchronous console sink that controls in values, keys and event names are escaped (C0, DEL and C1), each call is one physical record, and redaction still holds. test_observability.cpp gains a scenario for the terminator surviving a redacted last token.

  • Directory durability and authenticated lookup (CSAZ-5). Schema 18 stores publication independently of join rules and defaults previously unrecorded publication to private. Creation persists it with initial membership; PUT commits before updating memory. POST publicRooms runs after authentication and account-state checks, including remote lookups. ADR-0099.

  • Presence sharing and input bounds (CSAZ-8). Sync presence is restricted to current joined peers. PUT requires the specified presence enum and optional string status, bounded locally to 1024 UTF-8 bytes before stream allocation. Invalid updates preserve the stored row and sync watermark. ADR-0098.

  • Atomic administrative media writes (DB-5, partial). Quarantine, release and removal commit metadata, optional blob changes and required audit rows together before repository state or metrics change. Failed writes return 500. Upload and authentication token/account write-failure paths remain open. ADR-0100.

  • Large PostgreSQL room snapshots (DB-2, partial). Scoped relation queries join events using one room parameter rather than one parameter per event. A real two-handle regression reproduces the old 128-parameter failure. Generic failed worker reload handling remains open. Before Pages integration, the completed full suite passed 54 groups (0 failures/timeouts); focused security flows passed 491 assertions in 13 cases, and real PostgreSQL regressions passed 256 assertions in 2 cases.

  • Outbound authority and socket pin agreement (OUT-1, OUT-2). Matrix server names reject malformed authorities before discovery. Outbound and media-redirect URLs share libcurl's strict URL parser; every actual socket destination must match an approved numeric address and port. Proxies and connection reuse cannot bypass the check. Real TLS tests cover multiple pins, IPv6, encoded paths and refused authorities. ADR-0097.

  • Complete mainline ancestry and bounded auth graphs (EVT-5, EVT-9). State resolution loads power-level ancestors through the event source and refuses incomplete or cyclic ancestry. Room-v12 conflicted-subgraph work counts distinct events and traverses the shared DAG once. Corrected conformance fixtures reproduce both old defects; the combined authorization and state-resolution checks pass 20,994 assertions in 129 cases. ADR-0095.

  • Remote quarantine enforcement (MED-2). Held remote media returns 451 without payload bytes and cannot enter thumbnail processing. Storage admission no longer counts as successful delivery; response conversion refuses unexpected internal success statuses. Repository and real-HTTPS regressions failed before the fix and pass afterward in the parent checks.

  • Worker restart recovery (ISO-3). A failed spawn no longer ends supervision or calls waitpid(-1). Retries retain exponential backoff, reset it only after sustained child/IPC health, and remain interruptible during shutdown. A real-child regression reproduced permanent loss of recovery before the fix; the focused case passes 28 assertions. ADR-0096.

  • Sync long-poll admission and overflow-safe timeouts (HTTP-4). v3 and sliding sync share per-account and per-device admission, including queued waits. Excess requests and refused handoffs return 429 instead of occupying the main request pool. Oversized decimal timeouts saturate before applying the existing 120-second ceiling. The transport regression passes 224 assertions in one focused case; the combined suite also passes.

  • Durable media privacy and re-upload removal (MED-1, MED-3). Restart restores legacy endpoint visibility instead of exposing new uploads. Re-uploading removed bytes revives their single storage identity, so live lookup, persistence and later removal address the same blob. Restart regressions reproduced both defects before the fixes and pass afterward.

  • WIP: medium-severity findings from the 2026-09-29 security audit. The report contains 31 distinct medium-severity findings, not 20. Their per-finding status is tracked in docs/todos/capability-gaps.md; this branch does not yet resolve all of them. The verified first batch passed 157 unit assertions in 16 cases, 367 integration assertions in 10 cases, and 20,422 state/creator conformance assertions in 18 cases. The completed fresh full suite passes 54 targets, with 0 failures and 0 timeouts (exit 0). All new regression tags executed.

  • FIXED: implicit v12 creator privileges in power ordering (EVT-8). The sorter resolves the room's implicit create event through the existing event source and preserves candidate-specific power-level ancestors. Both the original and additional creators sort at infinite power; missing create events fail closed. A new BDD conformance regression failed before the fix and passes afterward; the focused state-resolution suite passes. Full-suite verification of the combined medium-fix branch is now green.

  • PARTIAL: published directory visibility (CSAZ-5). /publicRooms excludes rooms not explicitly published, and public createRoom visibility sets the directory flag. Focused directory and creation tests pass (672 assertions in 14 cases). Durable visibility and authentication on POST remain outstanding; CSAZ-5 is not yet closed.

  • Stripped invite/knock room summaries (CSAZ-7). Generated invitation snapshots and client reads now retain only the four stripped-state keys, seven room-summary types, and the recipient's own membership. Legacy/federated snapshots are re-pruned and deduplicated on read; invitation-time decoration is preserved. Sliding-sync invite reads share the same protection. The restrictive allowlist is a disclosure policy, not a protocol prohibition (ADR-0088). After the failing baseline, independent combined-tree focused verification passed 1,286 assertions in 17 cases; full-suite verification is now green.

  • Federation membership validation, receipt ACLs, and joined-subject admission (FED-6, FED-8). send_join, send_leave, and send_knock reject events whose membership, type, sender/origin, state key, or room/event identifiers do not agree with the request. The mutation sink repeats the checks and derives persisted membership from the event. Receipt EDUs check the authenticated origin against each room's ACL independently, retaining allowed rooms in mixed batches, and now also require the receipt subject to be a joined member of that room. Parent verification passed 667 assertions in 45 focused federation cases; the new joined-subject regression adds 67 assertions in 2 focused cases; combined full-suite verification is now green.

  • Unsolicited PDU admission and bounded pending joins (FED-11). The common sink refuses uninterested-room PDUs before stream allocation, backfill, cache mutation or persistence. Current local join/invite/knock membership permits normal processing; remote-only, departed and banned memberships do not. Outbound joins establish RAII reservations and defer concurrent PDUs in bounded transient queues (32 rooms, 32 PDUs and 512 KiB JSON per room), deduplicating event IDs and refusing overlaps/excess. Failed or throwing joins discard their queues. Successful joins drain through the common sink after committing verified state and membership, outside every global-lock recursion level. Solicited bootstrap/backfill paths remain explicit (ADR-0089). Independent focused unit, lifecycle, and existing join/backfill verification passed; full-suite verification is now green.

  • FIXED: restricted-join authorising-server signature verification (EVT-1). The restricted/knock-restricted join authorization rule now requires a signature from the authorising user's homeserver in the event's signatures object. Federation PDU ingestion cryptographically verifies that signature after resolving the authorising server's key, and state resolution enforces the structural presence of the signature without network access.

  • FIXED: application-service foreign-user assertion (AUTH-6). appservice_owns_user now rejects any user_id whose domain is not the local homeserver before matching namespace regexes, preventing a compromised appservice from having this server sign events as a foreign user.

  • FIXED: application-service sender_localpart reservation (AUTH-3). Every appservice's sender_localpart user is created at startup if missing, and ordinary /register and /register/available reject that localpart as reserved.

  • FIXED: room-version-aware creator authorization (EVT-7). v1-v10 creator identity comes from content.creator; v11+ uses only the create event sender, strips client-supplied legacy creator content on local room creation, and no longer treats the create sender as joined for ordinary sends or power edits. Creator bootstrap joins require exactly the authoritative create event as their sole predecessor; v12 derives that ID from the room ID, and additional creators do not receive implicit membership. EVT-7 conformance passed 108 assertions in 9 cases; related auth conformance passed 467 assertions in 97 cases.

  • WIP: complete state-resolution mainlines and bounded v12 conflicted subgraphs (EVT-5, EVT-9). Mainline ordering now fetches each required power-level ancestor through the shared event source and fails closed on missing, malformed, cyclic, or depth-truncated chains. The v12 subgraph walk processes each distinct reachable auth event once, rejects cycles and malformed/missing nodes, and applies the existing auth-chain event cap. Focused conformance verification is pending.

  • FIXED: bounded Argon2id admission (AUTH-4). A process-wide counting semaphore caps the number of concurrent password and registration-token Argon2id verifications. When saturated, /login, /register, and /register/m.login.registration_token/validity return 429 / M_LIMIT_EXCEEDED before any memory-hard work runs, and shed requests do not count toward the failed-login lockout. Verification still runs outside the global runtime mutex. ADR-0090.

  • PARTIAL: sync timeout cap and rate-limit key normalisation (HTTP-3, HTTP-4). Rate-limit policy lookup uses the normalised route so varying path/query segments share buckets. /sync and sliding-sync long-poll timeouts are capped at 120 s. HTTP-4 still needs per-user long-poll admission. HTTP-3 needs dedicated bypass regression verification.

  • HTTP-3 route-bucket normalization and bounded eviction. Known dynamic Matrix paths use finite route templates, unknown paths share one fallback bucket, and policy lookup still honors original operator prefixes. The bounded bucket tables now evict the least-recently-used entry in O(1) expected time. BDD regressions cover media, directory, room event/state, relations, to-device, account-data, unknown paths, and LRU behavior. Focused verification is pending.

  • DB-3 explicit database selection. PostgreSQL startup now fails when its URI file is missing, unreadable, or empty instead of silently opening an ephemeral store. A programmatic-only memory backend is available to test fixtures and is not accepted by config parsing; non-persistence fixtures explicitly select it through the shared helper. Database-related focused verification and the pre-Pages combined suite passed.

0.12.16

  • NEW: documentation site on GitHub Pages. The documents under docs/ are published with MkDocs Material at https://omg-software.github.io/Merovingian/, configured by mkdocs.yml. The navigation now covers every published document: architecture and module design docs, the ADR register, security docs and audit reports, operations, development and dependency reviews, the Matrix v1.19 Client-Server API reference, the road to 1.0, and this changelog. Agent instruction files, the vendored Matrix spec mirrors, superseded generated references and working notes are excluded. The landing page's Material icons now render (pymdownx.emoji was missing). ADR-0101.

  • NEW: the site is built on every branch and deployed from main. .github/workflows/pages.yml runs on every push and pull request with no path filter. It runs tests/tooling/test_docs_site.py, then mkdocs build --strict with link and anchor validation, and uploads the rendered site as the docs-site artifact. Only a push or manual run on main deploys to Pages, and only the deploy job holds pages: write and id-token: write.

  • NEW: repository links work on the site. scripts/mkdocs_site_hooks.py rewrites links to files the site does not publish (source, workflows, module AGENTS.md files) so they open on GitHub at the commit the site was built from, and links into docs/matrix-v1.19-spec/ so they open the same section of https://spec.matrix.org/v1.19/. It also publishes CHANGELOG.md as the changelog page and stamps the footer with the meson.build version. Before this the strict build failed with 42 broken-link warnings.

  • SECURITY: documentation dependencies are hash-locked and patched. mkdocs-material moves from 9.6.20 to 9.7.7, fixing the search-suggestion DOM XSS GHSA-xvg9-69gf-fjrf that failed the dependency triage gate. requirements-docs.txt is now a hash-locked resolution of requirements-docs.in covering every transitive package, and CI installs it with pip --require-hashes. New dependency review: docs/dependencies/mkdocs.md.

  • FIXED: malformed headings. docs/media-repository.md and docs/todos/capability-gaps.md began with #Title, which is not an ATX heading, so neither had a title.

  • TESTS: new docs-site-tooling suite (tests/tooling/test_docs_site.py): workflow triggers, deploy gating and permissions; hash-locked dependencies and the minimum safe mkdocs-material; every published document having a navigation entry; exclusion of agent and spec-mirror files; and the link rewriting hook.

0.12.15

  • FIXED: critical logs lost when the process exits after hardening refusal (LOG-1). LOG_CRITICAL messages now bypass the asynchronous writer queues and are written synchronously to console and file, so startup/hardening refusal lines are visible before the main thread exits. ADR-0087.

  • FIXED: heap-use-after-free in outbound federation signing (CRY-2). federation::OutboundCall now owns its copy of the Ed25519 signing secret in a core::SecretBuffer instead of borrowing a span from runtime.database .signing_secret_key. Every caller copies the secret while holding runtime.mutex and moves it into the call, so releasing the mutex around the network round trip can no longer dangle on a rotated key. ADR-0086.

  • FIXED: OpenBSD build failure in TLS layer (TLS-1). src/homeserver/tls.cpp now detects LibreSSL at compile time and omits the SSL_has_pending call that is not present in the OpenBSD base system.

  • FIXED: FreeBSD/NetBSD build failure in sync history-visibility tests (SYNC-1). tests/unit/test_sync_history_visibility.cpp now explicitly includes <algorithm> so std::ranges::find is visible on libc++ implementations that do not pull it in transitively.

  • FIXED: OpenBSD/NetBSD test failures caused by platform limits (BSD-1). tests/unit/test_logger_writers.cpp skips its thread-count assertions when /proc/self/task does not enumerate process threads reliably, and tests/unit/test_net_connection_parker.cpp no longer requires 200 concurrent socketpairs to all be accepted on runners with tight per-process file-descriptor limits.

  • FIXED: OpenBSD integration-test timeout flake (BSD-2). tests/meson.build raises the integration-tests timeout from 600 s to 900 s; the emulated OpenBSD runner frequently takes ~580 s and was hitting the old limit.

  • FIXED: worker-pool starvation via idle and slow connections (HTTP-1) and keep-alive lifetime limits (HTTP-8). New net::ConnectionParker and homeserver::HttpConnectionDispatcher hold every connection that is not being served on one poll(2) thread and dispatch it to the main request pool only once it is readable. A single client address can hold at most max(1, request_threads / 4) workers at once (trusted reverse proxies are exempt). Request bodies are read under a continuous minimum rate of 16 KiB/s after a 10 s grace. Media uploads above the transport cap authenticate the request head before any body bytes are read. Connections are closed after 1 000 requests or one hour of lifetime. New config key server.http.request_threads (4..256, default 16) sizes the main pool and is wired to the client-triggered outbound proxy caps so they stay at half the pool. ADR-0077; amends ADR-0072.

  • FIXED: state-resolution and event-authorisation divergences (EVT-2, EVT-3, EVT-4, EVT-6). m.room.power_levels is now read from the resolved room state, not from the most recent event with that type. Negative per-user power levels are honoured. State resolution correctly handles partitions with three or more groups and applies Kahn topological ordering to power-level events before the mainline tie-break.

  • FIXED: SIGPIPE handling and thread-start ordering (HTTP-5, ISO-1). Every executable entry point ignores SIGPIPE. No thread is started before process hardening and seccomp are installed, and seccomp filters are applied with TSYNC. ADR-0082.

  • FIXED: federation worker signing oracle (CRY-1). The federation worker no longer carries a signing provider; the main process refuses sign_request frames from the worker. ADR-0078 supersedes the relevant part of ADR-0015.

  • FIXED: bad-signature backoff charged to the source address, not the claimed origin (FED-4). Failed signature verification now accrues backoff against the connecting peer's address and the backoff decays. ADR-0081.

  • FIXED: PostgreSQL bytea round-trip (DB-1). bytea columns are decoded from the server's hex form on read and bound as binary on write, so the signing key and other binary rows survive a restart on PostgreSQL.

  • FIXED: send_join state binding and own-domain verification (FED-1). The state array of a send_join response is bound to the room being joined. Events whose sender domain matches the local server are now verified against our own signing keys, and the federation resolver refuses to fetch keys for our own server name. ADR-0083.

  • FIXED: federation room reads and missing-events bounds (FED-2). Federation endpoints that return room events or state now require the origin server to be a member of the room or for the room to be world-readable, and /get_missing_events is bounded.

  • FIXED: audit-row flooding and retained prepared statements (AUTH-1, AUTH-11). Unauthenticated rejection paths that write audit rows are rate-capped, use a bounded in-memory window (1 024 rows) and 255-byte fields, and no prepared statements are retained across calls. ADR-0080.

  • FIXED: to-device sender binding, inbound invite authorisation and device-list fan-out (FED-3, FED-5, FED-7). To-device EDUs bind the sender, are deduplicated by message_id, and cannot overwrite a ban. Inbound invites are authorised against room state before acceptance. Device-list EDUs fan out only to sharers of the room, deduplicated. ADR-0085.

  • FIXED: sliding-sync room access and private receipts (CSAZ-1, CSAZ-4). Sliding-sync subscriptions are refused for rooms the user cannot access, and m.read.private / m.fully_read receipts are returned only to their owner.

  • FIXED: client read gates and history visibility on every read path (CSAZ-2, CSAZ-3). initialSync, /members, /state, /messages, /context, /event, /search, /relations, /threads, /sync and sliding sync now judge history visibility from the room state recorded at each event and apply room-read access checks. Banned users see state as of the ban. ADR-0084.

  • FIXED: client-triggered outbound budget and remote-media fetch gate (HTTP-2, OUT-7). publicRooms?server=, remote room-alias lookups and remote media downloads now take a slot in a bounded in-flight budget before any outbound call and run under a short deadline. security.media.remote_fetch_enabled=false now blocks remote media fetches before discovery. ADR-0079.

Operator- and client-visible behaviour changes in 0.12.15

  • History visibility is now enforced on every client read path. Pre-state-group history is visible only to users who were joined when the event was sent.
  • Banned users see room state as of their ban event.
  • Sliding sync enforces timeline_limit ≤ 100 and caps subscriptions and required_state pairs to 256 each.
  • Client-triggered outbound proxy calls return 429 M_LIMIT_EXCEEDED when the in-flight budget is exhausted.
  • security.media.remote_fetch_enabled=false now really blocks remote media fetches and thumbnails.
  • Inbound m.receipt events now accept only m.read; other receipt types are rejected.
  • The unauthenticated audit window is 1 024 rows.
  • New configuration key: server.http.request_threads (4..256, default 16, restart required).

0.12.14

  • DOCS: second full security audit (September 2026). docs/security-audit-report-2026-09-29.md records a sequential, area-by-area audit of eleven high-risk surfaces against the Matrix v1.19 spec. Each area had a specialist auditor and an independent adversarial verifier, and every high or critical finding was re-checked in code.
  • Totals: 101 verified findings: 1 critical, 23 high, 31 medium, 46 low.
  • Most serious:
    • Any local user can take over a room: send_join response state is not bound to the room being joined, and events claiming our own domain are accepted without a signature check (FED-1).
    • Federation read endpoints and sliding sync perform no membership or history-visibility checks (FED-2, CSAZ-1, CSAZ-3).
    • Several auth-rule and state-resolution divergences from the spec (EVT-1 to EVT-6).
    • A compromised federation worker can have the main process sign arbitrary bytes, and its logger threads escape seccomp and Landlock (CRY-1, ISO-1).
    • The 8-thread request pool can be starved by one client (HTTP-1).
    • On PostgreSQL the signing key cannot be read back after a restart; confirmed against a live PostgreSQL 16 (DB-1).
  • Recommended fix order and doc corrections: the report gives the order to fix in and lists the documentation statements the code contradicts.
  • No production code was changed.

0.12.13

  • FIXED: build failures on newer Linux kernel headers and on the BSDs. landlock_ruleset_attr gained members (scoped, quiet_access_*) that the designated initializer in landlock_hardening.cpp left out under -Werror; it is now value-initialised, so every byte the kernel does not understand is zero, as the kernel requires. client_address.cpp now includes <sys/socket.h> for AF_INET/AF_INET6 (FreeBSD, OpenBSD), and the Landlock ops table and its access-rights helper are compiled on Linux only (FreeBSD, NetBSD). OpenBSD has no RLIMIT_AS, so the thumbnail decoder's memory cap is RLIMIT_DATA there, which on OpenBSD also bounds anonymous mmap (media::decoder_memory_limit_resource()); skipping the cap would have weakened a fail-closed control. Two concurrency tests used std::jthread, which the libc++ on FreeBSD and OpenBSD lacks; they now use tests/support/joining_threads.hpp. NetBSD linked pkgsrc's OpenSSL (libssl.so.3) while pkgsrc's curl and postgresql17-client link the base OpenSSL (libssl.so.16), so the werror link refused two OpenSSLs in one process (this also failed on main). The NetBSD CI and package jobs, the pkgin developer setup and the pkgsrc scaffold no longer pull in pkgsrc openssl, so the base OpenSSL is the only one. The test TLS servers leaked every socket they accepted (TlsConnection borrows its descriptor; they closed it only on a failed handshake), about one per outbound call, until OpenBSD's low descriptor limit broke later tests; they now own it in a core::SocketHandle, as the production server already does, and [descriptor_leak] guards it. CI test fixes: the decoder hardening scenarios assert the sanitizer build's own contract (it skips RLIMIT_AS and the seccomp filter), and the PostgreSQL role scenarios make only the newest migration pending, not a gap.

  • REMOVED: dead select_auth_events and AuthChain helpers. They had no production callers, and select_auth_events named m.room.join_rules for every membership, against the spec. Their three scenarios, the last in tests/conformance/test_event_authorization.cpp, went with them and the file was removed. The rules they encoded are now covered against the code that creates events: the v12 create-event exclusion in [auth_events_selection], and the third-party invite rule in the third_party_signed join scenario ([3pid]).

  • DOCS: stale "membership path lacks receipt checks" statements corrected. src/federation/AGENTS.md rule 5 and the capability-gaps soft-fail row still said send_join/send_leave/send_knock skipped the three receipt checks; they have run them since ADR-0064 phase B2 on this branch. A newly found gap, restricted-room joins through this server as the resident, is recorded in docs/todos/capability-gaps.md.

  • FIXED: leaves, kicks and bans named m.room.join_rules in their auth_events (MEDIUM, federation). Auth events selection names join_rules for a membership of join, invite or knock only, and auth rule 3.2 rejects any auth_events entry the selection does not name. Every leave, kick and ban this server created, and every leave built from its make_leave template, carried it and was therefore rejected by conformant servers (and, for remote leaves, by this server). Both auth_events_for_room and the membership template now follow the selection. Tests: [auth_events_selection] in tests/conformance/test_client_server_conformance.cpp and tests/conformance/test_pdu_ingestion_conformance.cpp.

  • FIXED: a backfilled event naming a stored event's ID skipped verification (LOW). verify_and_store_backfilled_event treated any event whose JSON "event_id" field named a stored event as already verified. The field is the origin's to write, so a forged /event_auth chain entry reusing a stored event's ID, with a membership the signature does not cover, was accepted and let an unverified snapshot through. The shortcut now also requires the stored event to be the same event (equal reference hash). Test: tests/integration/test_pdu_ingestion_backfill_flow.cpp ([event_auth_forged_id]).

  • FIXED: backfill stored whatever an /event/{eventId} fetch returned (LOW). Both fetchers (per missing reference, and for /state_ids snapshots) verified and stored the returned event without checking it was the one requested, so an origin could plant any validly signed event of its choosing. The returned event's ID, computed under the room version, must now equal the requested ID; otherwise it is dropped. Test: tests/integration/test_pdu_ingestion_backfill_flow.cpp ([event_id_mismatch]).

  • FIXED: room version 3 event IDs used the wrong base64 alphabet. rooms/v3.md defines the event ID as the reference hash in standard Unpadded Base64; only v4 moved to URL-safe. Every version used URL-safe, so any v3 event whose hash contained + or / (most of them) got an ID other servers do not compute. RoomVersionPolicy::event_id_url_safe_base64 is now false for v3 only. Test: tests/conformance/test_events.cpp ([v3_event_id]).

  • CHANGED: room versions 1 and 2 are no longer supported (ADR-0076). They were advertised and creatable, but their event-ID format ($localpart:domain carried in the event), the signature they require from the event ID's domain, and v1's state resolution algorithm were never implemented, so every v1/v2 event got a wrong event ID. Both are removed from the room version registry and refused everywhere: createRoom answers 400 M_UNSUPPORTED_ROOM_VERSION, they are no longer in m.room_versions or the outbound make_join ver list, and an invite into one (including a v1 invite for a room whose version is not known locally, which the spec says implies v1 or v2) answers 400 M_INCOMPATIBLE_ROOM_VERSION with room_version instead of a 500. Tests that counted twelve versions or used v1 as a supported version were corrected with the user's approval; the scenarios that used v1 to exercise the original auth rules now use v3, which shares them. Tests: [v1_v2_unsupported] (conformance and unit capabilities).

  • FIXED: send_join response events were not held to the signing-key validity rule (ADR-0075). filter_verified_send_join_events verified signatures without checking that the key was valid at each event's origin_server_ts. Both it and authorize_federation_pdu now use one function, check_signing_key_valid_for_event. Test: tests/unit/test_federation_invite_join.cpp ([key_validity]).

  • FIXED: event signing-key validity checked against the wrong time (MEDIUM, ADR-0075). authorize_federation_pdu rejected a PDU when its sender's key had expired now, in every room version, and backfill skipped the check. The spec measures it against the event itself: from room v5 the key's valid_until_ts MUST be at least the event's origin_server_ts (rooms/v5.md), and versions 1 to 4 MUST ignore valid_until_ts. Every path (/send, membership and invite endpoints, backfill, and main's re-verification of worker relays) now applies that rule; an event sent after its key expired is refused, and an old event whose key has expired since is accepted. A fetched key is cached for at most 7 days (min(valid_until_ts, fetched_at + 7 days), rooms/v5.md), which also bounds the resolver's stale-key fallback. Test fixtures whose keys expired before their own events were sent were given realistic validity, and one unit scenario that required the current-time rejection was corrected with the user's approval. Tests: tests/conformance/test_pdu_signature_conformance.cpp ([key_validity]). See docs/threat-model.md for the residual (backdated events within a key's capped validity).

  • FIXED: predictable media IDs and unauthenticated legacy media endpoints (MEDIUM, security-audit finding M05). Local media IDs were minted from a counter and content-digest prefix, making them enumerable, and the deprecated /_matrix/media/v3/download and /thumbnail endpoints remained reachable for every upload. upload_local_media now mints 16-byte (128-bit) CSPRNG IDs encoded as 22-character URL-safe base64 strings; each upload gets a distinct ID even when the same blob is deduplicated. New uploads set legacy_endpoint_visible = false, so the unauthenticated v3 routes return 404 M_NOT_FOUND while authenticated /_matrix/client/v1/media/download and /thumbnail routes continue to serve them. Pre-upgrade rows default to legacy_endpoint_visible = 'true' via migration 016, preserving existing links. Tests: tests/unit/test_media_repository.cpp ([m05]), tests/integration/test_media_repository_security.cpp ([m05]). See ADR-0068, docs/media-repository.md, and docs/database-persistence.md.

  • FIXED: membership-path receipt checks (ADR-0064 phase B2). send_join / send_leave / send_knock now run the same receipt checks as /send: a content-hash mismatch redacts instead of rejecting; the event is authorised against its own auth_events, the state before it, and current state, with rejection (stored, 403, never applied to state) and soft failure (stored, resolved, never a forward extremity, membership never flipped) handled per spec. Rejections are logged with their reason. The previously failing test fixtures have been rebuilt to send realistic events with proper prev_events, auth_events, content hashes, and (for room v12) reference-hash event IDs; the checks themselves were never relaxed. Full suite is green (Ok: 54, Fail: 0, Timeout: 0). See the resolved handover in docs/todos/capability-gaps.md under "RESOLVED (0.12.13)".

  • FIXED: inbound PDU backfill of missing prev_events and auth_events (ADR-0064 phase C). When an inbound /send PDU names references this server has not seen, ingest_pdu_event now releases the runtime locks and fetches them from the sending server: first a single POST /_matrix/federation/v1/get_missing_events/{roomId} call (up to 20 events), then individual GET /_matrix/federation/v1/event/{eventId} calls for any remaining missing references, with a hard cap of 5 outbound calls per PDU. Every returned event is verified independently — content hash (mismatch redacts), Ed25519 signature, auth_events selection, and auth against its own auth_events — before being stored as an outlier with a recorded after-state group. If references remain missing after the capped attempt, the original PDU still returns missing_prev_state and is not applied. Tests: tests/integration/test_pdu_ingestion_backfill_flow.cpp ([pdu_ingestion][backfill]).

  • FIXED: backfilled missing events now run the step-5 state-before check (HIGH). verify_and_store_backfilled_event previously verified a fetched event only against its own named auth_events (step 4) and stored it as an outlier whose after-state included the event. It now also authorises the event against the state before it, matching ingest_pdu_event. Events that pass step 4 but fail step 5 are stored with status rejected; their after-state group is the state before the event, so later events that reference the rejected event cannot inherit its state. Test: tests/integration/test_pdu_ingestion_backfill_flow.cpp ([pdu_ingestion][backfill][conformance]).

  • FIXED: /state_ids fallback for backfill gaps (HIGH). When /get_missing_events and per-event fetches cannot close a missing- prev_event gap, backfill_missing_pdu_references now falls back to GET /_matrix/federation/v1/state_ids/{roomId}?event_id=... to obtain the resolved state at the missing event. The returned pdu_ids and auth_chain_ids are capped, every named event is fetched via /event/{id} and verified independently, and only the verified state events are used as the snapshot. The missing event itself is then fetched and authorised against that snapshot as its state-before, bypassing the usual requirement that its own prev_events already have recorded state groups. Oversized responses and unverifiable snapshots are rejected, keeping the fail-closed behaviour. Test: tests/integration/test_pdu_ingestion_backfill_flow.cpp ([pdu_ingestion][backfill][conformance]).

  • FIXED: /state_ids fallback now uses spec-conformant /event_auth for historical state events (HIGH, ADR-0069 option A). Snapshot state events whose prev_events have no recorded state groups are no longer rejected outright. Instead, when the local state-before is unavailable, backfill_state_ids_snapshot falls back to GET /_matrix/federation/v1/event_auth/{roomId}/{eventId} to fetch the full auth chain, verifies and stores each returned PDU against its own auth_events, then stores the snapshot event as an outlier (amended by ADR-0070 below: it carries no recorded state). Snapshot materialisation uses a separate 100-call outbound budget and a 1000-event cap so that a large legitimate room does not exhaust the general PDU backfill budget. Fail-closed checks remain: the fallback is only attempted for snapshot state events, only when their prev_events truly lack state groups, and only after the auth-chain PDUs verify. Test: tests/integration/test_pdu_ingestion_backfill_flow.cpp ([pdu_ingestion][backfill][conformance]). See ADR-0069. ADR-0069 has been corrected: it named deciders and a date (2026-09-22) that were not accurate. It was ratified on 2026-09-27 with the ADR-0070 amendment. ADR-0064's inaccurate "Deciders" line was removed, and its snapshot cap text now matches the code (1000 events, 100-call budget).

  • FIXED: an event verified only against its own auth_events no longer lends a state to later events (MEDIUM, ADR-0070). The /event_auth path above gave each such event an after-state of its own auth_events plus itself, so a later PDU naming it as a prev_event was authorised against a thin state the origin chose, rather than the room's state before it (spec receipt check 5). Such an event now carries no state group, and a PDU building on it triggers a fresh /state_ids fallback at that event. It is held as missing_prev_state if the origin cannot supply that state. A stored event without a state group (an /event_auth outlier, or one older than ADR-0064's state groups) gains one when a later fallback supplies a verified state before it and it passes auth against that state. Snapshot materialisation now skips every already-stored event, so such outliers are not re-fetched on each snapshot. Tests: tests/integration/test_pdu_ingestion_backfill_flow.cpp ([event_auth_outlier], and the updated [backfill][conformance] scenario). See ADR-0070.

  • TEST: flaky FD_CLOEXEC check on accepted sockets fixed. The listener integration test found the server's end of its connection by scanning /proc/self/fd for a socket whose peer port matched, so under parallel load it could inspect an unrelated socket that shared the port. It now matches the full local and peer address, and the scenario plants such a lookalike (a non-close-on-exec socket on a lower fd) to prove the match holds. Test: tests/integration/test_http_server_listener_flow.cpp ("marks accepted client sockets close-on-exec").

  • REFACTOR: no raw pointer in the worker IPC in-flight guard (audit item 10). InFlightGuard in src/homeserver/worker_pool.cpp now holds an ipc::IpcChannel& instead of a pointer, per the project's no-raw-pointers rule. The channel was already never null there. No behaviour change.

  • FIXED: backfill dropped /get_missing_events children listed before their parents (LOW, audit item 9). A backfilled event is authorised against the state before it, which needs its prev_events stored first, but the results were handled in the order the origin sent them. They are now handled in ascending depth order (ties keep the response order). Test: tests/integration/test_pdu_ingestion_backfill_flow.cpp ([backfill_depth_order]). See docs/event-engine.md.

  • FIXED: the federation worker could start without its IPC socket (LOW, audit item 8). With main's stdin closed, the IPC socket pair came back as fds 0 and 3, so the child's end already sat on the fixed child fd 3 and the spawn's adddup2(3, 3) was a same-fd dup2, which some libcs treat as a no-op that leaves FD_CLOEXEC set. The new make_worker_ipc_socketpair moves the child's end past fds 3 to 5 first, as make_worker_secret_pipe already did for the secret pipes (the two now share one helper), and spawn_and_connect uses it. Test: tests/unit/test_worker_supervisor.cpp ([worker_ipc_fd]), which closes stdin in a forked child on the real kernel. See docs/hardening.md.

  • DOCS: underscores from an old find-and-replace removed from docs/hardening.md (audit item 11). Headings and prose such as "Cross_platform", "Build_time" and "Out_of_process" now read with hyphens; identifiers in code spans are untouched. The three documents that cite the "Out-of-process federation worker IPC security" heading were updated to match.

  • REMOVED: dead authorization code (audit item 7). events::authorize_event and events::membership_policy_allows had no production callers and did not follow the spec for bans and knocks. They were deleted with the helpers only they used (auth_rule_hook_name, power_level_allows) and the three conformance scenarios in tests/conformance/test_event_authorization.cpp that exercised only them. The auth rules the server enforces are authorize_event_against_auth_events. The remaining dead helpers, select_auth_events and AuthChain, were removed later on this branch (see below).

  • FIXED: refresh-token rotation broke lost-response retries and never detected reuse (MEDIUM, audit item 6, ADR-0074, migration 017). Per the spec, the presented refresh token now stays valid until the new access or refresh token is first used, so a client whose /refresh response was lost can retry (before, it was logged out). Presenting a refresh token that has been invalidated revokes the device's whole session and is audited as auth.refresh.reuse_detected. Migration 017 adds refresh_tokens.predecessor_hash and access_tokens.predecessor_refresh_hash. One unit scenario that required single-use refresh tokens was corrected with the spec citation, with the user's approval. Tests: [refresh_rotation] (conformance and integration), tests/unit/test_homeserver_auth_service.cpp.

  • FIXED: access tokens never expired after a restart (HIGH, found while doing item 6). Hydration rebuilt each in-memory session without its expires_at, and authentication reads only the session. Test: tests/integration/test_persistent_homeserver_flow.cpp ([session_restart]).

  • FIXED: the user directory returned deactivated accounts and ignored limit (LOW, audit item 5). POST /user_directory/search now skips deactivated local accounts, returns at most limit results (spec default 10) and sets limited when it truncated the list; it always said false before. Test: tests/conformance/test_client_server_conformance.cpp ([user_directory]).

  • FIXED: IPv6 clients could escape rate limits within one /64, and a client could pick its own bucket through X-Forwarded-For (LOW and MEDIUM, audit item 4, ADR-0073). Rate-limit buckets now group IPv6 clients by server.http.ipv6_client_prefix_length (default /64; IPv4 unchanged), for both the client-server rate limiter and the federation key-resolution budget. Behind a trusted proxy the client is now the rightmost X-Forwarded-For entry that is not itself a trusted proxy, with every header line read as one list; before, the leftmost entry was used, which the client controls when the proxy appends to the header. Tests: tests/unit/test_client_server.cpp ([rate_limit_keys]).

  • FIXED: redaction kept content keys the room version's algorithm drops (LOW, audit item 3). The redacted form feeds the reference hash, so each of these gave affected events a different event ID from other servers: m.room.aliases kept aliases through v10 (rooms/v6.md removed it; now v1–v5 only); m.room.member kept join_authorised_via_users_server in every version (now v9+; v8 keeps only membership); and m.room.third_party_invite kept signed (no version lists that event type; now stripped to {}). Two existing conformance scenarios in tests/conformance/test_redaction_conformance.cpp required the old behaviour and were corrected with spec citations, with the user's approval. Event IDs already stored are not recomputed. Test: [redaction_versions] (15 version/type combinations).

  • FIXED: join rules not gated by room version; knock_restricted joins refused (LOW, audit item 2). The join path accepted only restricted and the non-spec restricted_v2, so a legitimate knock_restricted join in a v10+ room was refused (rooms/v10.md rule 4.3.5: "If the join_rule is restricted or knock_restricted"). Each join rule is now recognised only in the versions that define it: knock from v7, restricted from v8, knock_restricted from v10, for both joins and knocks. restricted_v2 and any rule the version does not define are rejected, and a knock membership is rejected before v7. New RoomVersionPolicy flags carry the table. Test: tests/conformance/test_event_auth_rules.cpp ([join_rule_versions], 17 version/rule combinations).

  • FIXED: no per-IP connection cap (MEDIUM, audit item 1, ADR-0072). One host could fill the global connection budget with connections held just under the slow-request thresholds. Both accept loops now refuse a connection, before reading a byte or starting a TLS handshake, once its client holds server.http.max_connections_per_ip (new, default 64) connections. IPv6 clients are grouped by server.http.ipv6_client_prefix_length (new, default 64). Addresses in server.trusted_proxies are exempt. The slot is RAII and travels with the connection through every hand-off. --plan-config-reload now reports every server.http.* change (it reported none before), all restart-required. Tests: tests/unit/test_http_connection_limiter.cpp, tests/unit/test_config_parser.cpp, tests/unit/test_config_reload_plan.cpp, tests/integration/test_http_server_listener_flow.cpp ([connection_limit]).

  • FIXED: main trusted the federation worker's signature check on relayed PDUs (MEDIUM, ADR-0071, threat-model #450). A compromised worker could make main persist events impersonating any sender the room's state authorised. pdu_ingest, membership_ingest and invite_ingest now verify the sender server's signature with main's own remote_key_resolver, before the runtime lock is taken, and /send and membership envelopes are rebuilt from the verified event instead of the worker's framed fields; a frame whose event ID disagrees with the signed event is refused. The worker-flow fixtures that relayed unsigned events under a placeholder event ID now sign as the sender's server. Tests: tests/integration/test_worker_relay_signature_flow.cpp ([worker_relay_signature]), tests/integration/test_federation_worker_flow.cpp.

  • FIXED: data race on the server signing-key cache (HIGH, found while doing the item above). PersistentStore::server_signing_keys was an unguarded vector written by the remote-key resolver from the inbound path's parallel key fan-out and from backfill, both without the runtime mutex, while other threads read it. A concurrent store/lookup test crashed with SIGSEGV in 4 of 4 runs. The vector now has its own mutex, and readers that iterate use snapshot_server_signing_keys. Test: tests/unit/test_database_persistence.cpp ([signing-key][concurrency], also clean under ThreadSanitizer).

  • FIXED: push gateway and identity server clients skipped SSRF filtering on address resolution (MEDIUM). push_gateway_client.cpp and identity_client.cpp were calling CachedServerDiscovery::upstream().lookup_addresses(...) directly, which bypassed the private/loopback rejection applied by discover_server/resolve_federation_destination. Both clients now call the new CachedServerDiscovery::lookup_addresses_filtered, which rejects private, loopback, link-local, CGNAT, NAT64, multicast, and reserved addresses before any outbound connection is attempted. The IPv4/IPv6 classifiers in src/federation/security.cpp were expanded to cover CGNAT (100.64/10), multicast (224/4), reserved (240/4), NAT64 (64:ff9b::/96), and IPv4-mapped variants; address_set_allowed is now a public helper used by both federation discovery and the filtered lookup. The Application Service client intentionally continues to use the raw resolver: appservice URLs are operator-configured, not attacker-influenced, and the spec's own example uses cleartext HTTP. Tests: tests/unit/test_federation_security.cpp ([federation][security][ssrf]), tests/unit/test_push_gateway_client.cpp ([push][push-gateway][ssrf]). Updated src/push/AGENTS.md and src/identity/AGENTS.md.

  • FIXED: password re-authentication for UI-auth flows bypassed the per-account failed-login lockout (MEDIUM). verify_local_user_password was only checking the password hash, so an attacker with a stolen access token could guess the password through /account/password, /account/deactivate, /account/3pid/add, /keys/device_signing/upload, and the single/bulk /devices/{deviceId}/delete paths without ever sharing the /login failure budget. verify_local_user_password now returns PasswordVerificationResult (ok and retry_after_ms) and consults the same per-user failed-login counter as /login: it refuses to check the password while the account is locked out, records each wrong attempt against the authenticated user, and clears the history on a correct password. All six UIA call sites translate a non-zero retry_after_ms into 429 M_LIMIT_EXCEEDED with a Retry-After header. Test: tests/unit/test_homeserver_auth_service.cpp ([m02]). See ADR-0066 and updated docs/auth-identity.md.

  • FIXED: no size or field-count limits on federation PDUs (MEDIUM). parse_inbound_pdu_envelope now rejects any inbound PDU whose raw canonical JSON exceeds 65 536 bytes, whose prev_events array exceeds 20 entries, or whose auth_events array exceeds 10 entries — all before hashing or signature verification. parse_event_envelope and matrix_id_is_valid enforce the 255-byte caps on state_key, sender, room_id, and type. The shared constants live in include/merovingian/events/limits.hpp. Tests: tests/unit/test_inbound_ingestion.cpp ([m03]) and tests/unit/test_event.cpp ([m03]). See ADR-0067 and docs/event-engine.md.

  • FIXED: m.federate: false was not enforced for room versions 1–5 (MEDIUM). The content.m.federate sender-domain check in authorization rule step 3 was gated on room_v6_plus || room_v12, so v1–v5 rooms that disabled federation still accepted events from any domain. The check now applies to every room version: when m.federate is false, the sender's domain must match the create event's creator domain (or, for v11+/v12, the create event's sender). m.federate absent or true still permits cross-domain senders in all versions. Tests: tests/conformance/test_event_auth_rules.cpp ([m04]). See docs/event-engine.md.

Fixes every Critical and High finding from the September 2026 full-project bug audit.

  • Master key file now gets the same startup metadata check as every other secret file (HIGH). security.secrets.master_key_file — the root secret from which the signing-secret box key, access-token HMAC keys, and federation-worker IPC auth key are derived — was opened with a plain std::ifstream without validating owner-only, non-executable, regular-file permissions. A group/world-readable master key or a symlink swapped in at startup was silently accepted. validate_existing_secret_files in src/main.cpp now applies the same lstat-based, TOCTOU-safe check used for database.uri_file, security.registration.token_file, and listener TLS private keys. Operators upgrading from an earlier release may need a one-time chmod 0400 on the master key. Tests: tests/integration/test_secret_file_validation_flow.cpp ([secret_files]).

  • State resolution v2 could diverge from conformant servers by ignoring the auth difference (HIGH, consensus-critical). resolve_state_v2 only ever considered power events literally present in the two conflicted state groups it was handed, never the spec's full conflicted set (conflicted state set + auth difference, rooms/v10.md — Definitions). A power-level change reachable only through a conflicted event's auth_events chain — for example a promotion that authorised a later ban — was invisible to the resolver, so its effect was silently dropped and resolved state could differ from another server that correctly walked the chain. Room v12 (state-res v2.1, rooms/v12.md) additionally needed the conflicted state subgraph and an empty starting map for the iterative auth checks; neither was implemented, so v12 rooms used the wrong algorithm entirely (StateResolutionAlgorithm gained v2_1, distinct from v2). StateResolutionRequest now carries an event_lookup callback (include/merovingian/events/state_resolution.hpp) so the resolver can walk auth_events chains beyond the submitted state groups; production wires this to the persistent store in src/homeserver/local_http_router.cpp's state_conflict_resolver. The walk is bounded (events::max_auth_chain_walk_events, include/merovingian/events/limits.hpp, 20 000 events) and fails closed: a missing/unreachable event or an over-budget walk resolves to rejected_state_conflict rather than resolving on a partial chain (see ADR-0063). The iterative auth checks' own auth_events fallback (used when a required key is absent from the running state — spec: "Iterative auth checks") was also missing entirely and is now implemented in build_auth_event_map_from_state; without it, v12's empty starting map left almost every candidate event unable to find its own auth context. Tests: tests/unit/test_state_resolution_auth_diff.cpp ([state_res_v2] — auth difference, fail-closed missing/cycle/cap, determinism, v12 empty-start, v12 conflicted state subgraph) and four new scenarios in tests/conformance/test_state_resolution_conformance.cpp (ban vs. concurrent power-level demotion, a kick surviving an unrelated topic conflict, join-rule evasion, and the same-power/same-timestamp event_id tie-break).

  • Reverse topological power ordering read a candidate's sender power from the wrong source (HIGH, consensus-critical — found in review of the fix above). power_level_from_event read an m.room.power_levels candidate's sender power from that SAME event's own new content — a self-elevating power_levels event (one that grants its own sender a level it does not actually hold) ranked itself by the level it claims, not the level its own auth_events ancestor actually grants it — and read every other candidate's power from a shared "unconflicted" state map, which is simply the wrong source per spec (rooms/v10.md — Reverse topological power ordering, rule 1: power is read "looking at their respective auth_events"). Fix: find_auth_ancestor_context walks the candidate's own auth_events for its m.room.power_levels (and, for v12, m.room.create) ancestor via the same fail-closed AuthChainEventSource used for the auth difference, and feeds the result to events::effective_sender_power — moved out of authorization.cpp's anonymous namespace and declared in authorization.hpp so the ordering and the auth rules read power identically, including MSC4289 creator-infinite power for room v12. reverse_topological_power_sort's signature changed: EventJsonIndex + EventLookupFn instead of StateMap unconflicted, and returns optional<vector<StateEventReference>> — nullopt fails the sort closed (ADR-0063) when an auth_events entry needed to answer the question cannot be resolved, rather than guessing a default from a partial chain. mainline_order was audited against the same defect class and found not to have it — it already reads each event's own auth_events power-levels ancestor and never computes a power level at all, only a mainline position. Tests ([state_res_v2], tag [power-ordering], in tests/unit/test_state_resolution_auth_diff.cpp and tests/conformance/test_state_resolution_conformance.cpp): a self-elevating power_levels candidate loses to a genuinely higher-powered sender; a non-power candidate reads its own auth_events ancestor instead of a shared unconflicted power_levels event; a v12 room creator outranks any finite power_levels level; an unresolvable auth_events ancestor fails the sort closed; string-vs-integer power-level encoding (v1-9 vs v10+) is now exercised on the auth_events ancestor rather than a candidate's own content.

  • Thumbnail decoder worker hardening is now fail-closed. harden() in src/media/thumbnail_worker_main.cpp discarded the result of every hardening call (setrlimit x5, prctl(PR_SET_DUMPABLE), prctl(PR_SET_NO_NEW_PRIVS), the decoder seccomp filter, pledge, cap_enter) with std::ignore, then unconditionally read stdin and ran libpng/libjpeg-turbo on attacker-controlled bytes — a sandbox that failed to install left the decoder running fully unconfined, silently. Hardening is now media::apply_decoder_hardening() (include/merovingian/media/decoder_hardening.hpp, src/media/decoder_hardening.cpp): every applicable control must succeed or the worker writes the failed control's name to stderr and exits 1 before reading any input. The real syscalls sit behind an injectable DecoderHardeningOps function table so the fail-closed sequencing is unit tested per control (tests/unit/test_media_decoder_hardening.cpp, [thumbnail_hardening]) without installing a broken sandbox in the test process. The parent (src/media/thumbnailer.cpp) already turns a worker that exits before producing output into a normal failed-thumbnail response (502), so no parent-side change was needed.

  • Federation worker no longer opens the operator master-key file (finding N1, part 1 of 3). The worker derived its own copy of the IPC channel's auth key from the master-key file at every startup and restart, even though no worker-reachable code path ever needed the other keys derivable from that file (the signing-secret box key, the access-token HMAC keys) — a compromised worker could still read the file and derive them anyway. Main now derives the IPC auth key once (WorkerPool's constructor) and hands it to the worker over a second inherited pipe fd (--ipc-key-fd) at spawn time; make_worker_key_pipe writes the key and closes the write end, and the read end stays FD_CLOEXEC in main — a posix_spawn_file_actions_adddup2 places it at the fixed fd 4 in the child only, so no concurrent spawn in the multithreaded main process (another shard's restart, the thumbnail decoder) can inherit the pipe carrying the key. The worker (federation_worker::read_ipc_auth_key, src/federation_worker/ipc_key_fd.cpp) reads exactly the expected number of bytes, requires EOF immediately after, and fails closed (LOG_CRITICAL, non-zero exit) on a short, long, or missing key; federation_worker::clear_master_key_file then empties security.secrets.master_key_file on the worker's own config copy before homeserver::start_runtime() runs, so no worker code path can open that file even by accident. args.cpp requires the new --ipc-key-fd and validates it is distinct from --ipc-fd and not 0/1/2. See ADR-0062 for the full three-part design; parts 2 (a separate, least-privilege worker database login) and 3 (Linux Landlock filesystem restriction) are tracked follow-ups, not yet shipped.

  • Federation worker no longer shares main's PostgreSQL login, and never hydrates credential-bearing tables into its own memory (finding N1, part 2 of 3). Before this, a PostgreSQL-backed worker connected with the exact same login role as main, so a compromise could SELECT server_signing_keys.secret_key (still ciphertext, but exactly what part 1's fix stops the worker from independently decrypting) and every other credential-bearing table, even though no worker code path reads them. Two independent mechanisms close this:

  • A separate, least-privilege login, delivered the same inherited-fd way as part 1's IPC auth key. New config keys federation.worker.database_uri_file (a secret file holding the role's connection URI, validated the same way as database.uri_file) and federation.worker.allow_shared_database_credentials (an explicit, CRITICAL-logged opt-out). config::validate() rejects database.backend=postgresql with federation enabled and an empty database_uri_file unless the opt-out is set. homeserver::make_worker_key_pipe's pipe construction is generalised into make_worker_secret_pipe(secret, reserved_fds), reused by the new make_worker_db_uri_pipe (reserves fds 3, 4, and the new kWorkerDbUriFd 5); WorkerPool::WorkerPool derives the URI once and each WorkerSupervisor::spawn_and_connect passes --db-uri-fd <n> and a third posix_spawn_file_actions_adddup2, only when a separate URI actually applies. The worker (federation_worker::read_worker_database_uri, src/federation_worker/db_uri_fd.cpp) reads the URI until EOF (bounded to 4096 bytes, unlike the fixed-length auth key), and federation_worker::apply_worker_database_uri sets the delivered URI as the worker's own connection override while clearing its config copy's database.uri_file/runtime_role/migration_role, so the restricted login connects directly and never attempts a SET ROLE onto roles it was never granted. packaging/postgresql/ provision-federation-worker-role.sql provisions the role as an explicit per-table allowlist: SELECT (never INSERT/UPDATE/ DELETE — the worker's own store is a read-only snapshot; every write is relayed to main over IPC) granted table by table on exactly the nine tables database::federation_worker_table_allowlist names, with no ALTER DEFAULT PRIVILEGES and no GRANT ... ON ALL TABLES — a table a future migration adds is unreadable by this role until someone grants it deliberately.
  • A load profile, independent of which credential the worker holds. database::TableLoadProfile and database::federation_worker_table_allowlist (include/merovingian/database/persistent_store.hpp) decide which tables open_postgresql_persistent_store's row loader hydrates. TableLoadProfile::federation_worker is likewise an allowlist of nine tables — rooms, membership, current_state, events, event_edges, event_auth, event_signatures, room_aliases, and (column-restricted, never secret_key) server_signing_keys — derived by tracing every FederationRuntimeState callback the worker does not override to the table it reads; a table absent from the array is never hydrated. RuntimeStartOptions::database_load_profile is set to federation_worker unconditionally by WorkerEventLoop::run(), so even a worker running in the allow_shared_database_credentials=true degraded mode never pulls an unlisted table into its own process memory. server_signing_keys needed a column-level answer: the worker's remote-key cache legitimately reads/writes other servers' rows in this table, so load_persistent_rows uses a worker-specific 4-column query that never selects secret_key, and the SQL grant is itself column-restricted to match. A unit test (tests/unit/test_worker_db_uri.cpp, tag [worker_db_uri]) parses both the SQL grant list and the C++ allowlist from the source tree and asserts they name the same table set, and separately asserts every table migrations/*.sql creates is classified as allowlisted, never-read, or (for schema_migrations, needed to open any store) ungated. SQLite is unaffected by either mechanism — a single shared file offers no role to separate; part 1's file-access removal remains that backend's protection for the master key specifically, and the load profile is a defense-in-depth reduction there, not a boundary. See ADR-0062 and docs/database-persistence.md, "Federation worker least-privilege role", for the full design and the GRANT/REVOKE SQL.

  • Federation worker now restricts its own filesystem access with Linux Landlock (finding N1, part 3 of 3). Parts 1 and 2 stopped the worker opening the master-key file and main's database credentials through its own intentional code paths, but the worker still ran as an ordinary process with no filesystem sandbox — a compromise reached through a memory-safety bug, rather than one abusing an intentionally-opened file, could still open the master key, either database URI file, or TLS private keys directly off disk. platform::apply_worker_landlock() (include/merovingian/platform/landlock_hardening.hpp, src/platform/landlock_hardening.cpp) is now called from federation_worker::main() before the worker seccomp filter is installed (Landlock's three syscalls are not on that filter's allowlist, and applying Landlock first avoids adding them there for a one-time startup step) and before the event loop opens the database or handles any inbound request. It queries the running kernel's Landlock ABI version, builds a landlock_create_ruleset handled-access mask downgraded to whatever that ABI supports, adds one landlock_add_rule per path platform::build_worker_landlock_rules() derives from the worker's own config copy (the SQLite database directory, read-write, when database.backend=sqlite) plus a fixed set of best-effort, read-only or read-execute OS-integration paths (CA trust store candidates, resolver configuration, NSS/dynamic-linker library directories, timezone data), then calls landlock_restrict_self. The master key file, both database URI files, and the TLS private key paths are never in the rule set, and the worker refuses to start if any rule equals or is an ancestor directory of one of them (platform::find_landlock_rule_covering_secret, compared by path component after weakly_canonical) — a rule on a directory grants everything beneath it, so a master key kept beside the SQLite database would otherwise be exposed read-write. The CA grants cover only the certificate stores, never all of /etc/ssl or /etc/pki, which hold the conventional private-key directories. Three kernel-contract bugs found in review were fixed before this shipped: file rules requested directory-only rights (EINVAL on /etc/resolv.conf); landlock_restrict_self ran before PR_SET_NO_NEW_PRIVS was set (EPERM; now set immediately before it, failure fatal); and the worker, which inherits the main server's seccomp filter across execve, was killed by it because main's allowlist lacked the Landlock syscalls (now allowed there — Landlock can only narrow access). The forked real-kernel test now skips only when the kernel reports no Landlock ABI; it had been reporting the EPERM as a skip. When the running kernel has no Landlock support (older than Linux 5.13, or Landlock disabled at boot), the worker refuses to start unless the new federation.worker.allow_without_landlock=true opt-out is set, in which case it logs CRITICAL on every start — mirroring the fail-closed seccomp policy in ADR-0041. Any other Landlock failure (ruleset creation, a required rule, or landlock_restrict_self itself) is always fatal, regardless of the opt-out — the opt-out covers "this kernel has no Landlock", never "Landlock is present but broken". The real kernel calls sit behind an injectable LandlockHardeningOps function table (mirroring media::DecoderHardeningOps) so every fail-closed path is unit tested (tests/unit/test_worker_landlock.cpp, tag [worker_landlock]) without restricting the test process itself; a forked real-kernel scenario in the same file additionally proves a denied path is refused with EACCES and an allowed path still opens, skipping cleanly on a kernel without Landlock. See ADR-0062, part 3, for the full allowlist derivation and its strace evidence.

  • Main now caps in-flight federation-worker IPC requests per channel (HIGH). ADR-0027 left the IPC dispatch pools unbounded on the premise that their producer was the trusted local supervisor. After finding N1, the worker is the least-trusted process: a compromise there could flood main with queued IPC handlers until the OOM reaper killed client traffic too. federation.worker.ipc_max_in_flight_requests (default 256, restart-required) configures a per-channel semaphore on each worker's IPC channel. WorkerPool acquires a slot before queuing a handler and releases it when the handler finishes; requests over the cap receive an explicit error reply immediately instead of being queued. pdu_ingest replies with {"status":"main_overloaded"}; membership_ingest and invite_ingest reply with {"accepted":false,"status":503}; the worker maps these to a retryable HTTP 503 toward the remote so the transaction is retried rather than dropped. See ADR-0065, which supersedes the IPC-pool half of ADR-0027.

  • Phase A of spec-conformant PDU ingestion: delta state group storage (ADR-0064). State resolution v2 was fixed in this same release, but nothing in the ingest path actually populated PduIngestionResult:: state_conflict, so it never ran — ingest_pdu_event authorised each inbound PDU against current state only and wrote state events straight into current_state, letting two concurrent valid state events resolve as "whichever arrived last" instead of deterministically. This is the storage layer the later ingestion phases need to fix that; it does not itself change ingestion behaviour, local prev_events selection, or what clients see. Migration 015_event_graph_state.sql adds events.status ('accepted' | 'soft_failed' | 'rejected' | 'outlier', existing rows default to 'accepted'); extends the pre-existing but vestigial state_groups table with parent_state_group_id/delta_depth so it can anchor a delta chain (a group is a full snapshot when the parent is empty, or a delta from its parent otherwise, capped at events::max_state_group_delta_depth = 100 hops before a fresh snapshot is forced); and adds state_group_state, event_state_groups, and forward_extremities. Every pre-existing room is seeded with one snapshot state group built from its current_state, attached to its current forward extremities — a seeding step that, along with phase B's future per-event children lookup, filters event_edges by prev_event_id alone, outside the leading column of its (event_id, prev_event_id) primary key; migration 015 therefore also creates event_edges_prev_event_id ON event_edges (prev_event_id) (the project's first index, positioned before the seed statements so seeding itself uses it), fixing what was previously a full table scan per probe during seeding. state_groups gets no matching room_id index: no store function queries it by room_id, only by its state_group_id primary key. New store API in include/merovingian/database/persistent_store.hpp: create_or_reuse_state_group, read_state_group_full_state (bounded, cycle-detected, fails closed rather than ever returning partial state), find_state_group, update_forward_extremities/ find_forward_extremities, and set_event_status/find_event_status; implemented for SQLite, PostgreSQL, and the in-memory store, and added to the federation worker's least-privilege PostgreSQL role and table allowlist (packaging/postgresql/provision-federation-worker-role.sql, database::federation_worker_table_allowlist) since the worker already serves federation /state//state_ids locally and a later phase moves that onto state groups. state_group_edges remains vestigial and untouched. See ADR-0064, docs/database-persistence.md, tests/unit/test_state_groups.cpp ([state_groups]), and tests/integration/test_state_groups_flow.cpp.

  • Phase B1 of spec-conformant PDU ingestion: state resolution wired into ingest_pdu_event (ADR-0064, HIGH — this is the delivery-order divergence ADR-0064 exists to fix). Phase A above added the storage; nothing called it. ingest_pdu_event still authorised each inbound PDU against current state only, then wrote it straight into current_state — two concurrent, individually valid state events resolved as whichever committed last, not by spec resolution, so resolved state could diverge across the federation depending on delivery order. New module merovingian::homeserver::state_bookkeeping (include/merovingian/homeserver/state_bookkeeping.hpp, src/homeserver/state_bookkeeping.cpp): compute_state_before (the state before an event — a single prev_event's own after-state directly, several resolved via resolve_state_v2, failing closed to a new PduIngestionStatus::missing_prev_state — not a rejection; the transaction still returns 200 per spec — when a prev_event has no recorded state group), compute_state_after (state-before plus the event itself when it is a state event), record_event_state (the after-state group and forward-extremity bookkeeping for every accepted event), and recompute_current_state (current state as the resolution over the room's forward extremities, diffed against the cache and written only where changed through the existing database::store_state, so state_transitions/unsigned.replaces_state and the sync wake-up path need no new plumbing). Threaded missing_prev_state through the federation-worker IPC status mapping (src/homeserver/worker_pool.cpp/src/federation_worker/worker_event_loop.cpp) and the /send transaction per-PDU switch (src/federation/inbound_request.cpp) so a worker-relayed PDU reports it correctly and a transaction containing it still succeeds. Tests: tests/unit/test_state_bookkeeping.cpp and tests/unit/test_pdu_ingestion_state_groups.cpp ([pdu_ingestion] [state_groups]) — delivery-order independence (the core regression), a merge event's state-before as the resolution of both fork tips, per-event state-group correctness, forward-extremity fork/merge, and the missing-state fail-closed path, both directly against the module and end-to-end through ingest_pdu_event. At this point local event-creation paths still chose prev_events the old way and the join flow seeded no state group — closed by the next entry, same release.

  • Phase B1 completion: every local and inbound event path now participates in state bookkeeping (ADR-0064, HIGH — the phase B1 entry above closed the regression only for federation's /send transaction path; every OTHER path that stores an event was still the pre-ADR-0064 code, which is itself a regression against main: a locally sent message got no after-state group or forward-extremity update, so the very next inbound PDU naming it as a prev_event failed closed with missing_prev_state and was dropped, and a room joined after upgrading lost every inbound event the same way, because the join event had no state group either).

  • New choke point homeserver::store_local_event (state_bookkeeping.hpp/.cpp): computes state-before from event.prev_event_ids, stores the event, records its after-state group and forward-extremity update, and recomputes current_state — the same sequence ingest_pdu_event runs, except every failure here is a HARD failure (the whole request fails) rather than logged-and-ignored, since a local event has not yet been told "success" to anyone. New homeserver::forward_extremities_for_new_event replaces previous_events_for_room's old body ("the last event pushed into store.events for this room" — no relationship to the DAG once federation can fork a room's tip) with the room's real forward extremities, capped at the spec's 20-prev_events limit (events::max_prev_events_per_event) by highest depth. New homeserver::record_event_state_with_parent is record_event_state generalised to an explicit parent group, for the join-seeding path below.
  • persist_composed_event (used by create_room's initial-state chain, invite/join composition, and every ordinary send/state-send/redaction via send_event, which previously duplicated the raw store call instead of reusing this function) now calls store_local_event instead of database::store_event_with_state directly. ComposedEvent gained a room_version field so the choke point does not need to re-derive it (impossible for a room's own create event: store.state has no create row yet while composing it).
  • local_http_router.cpp's membership_acceptor (a remote user's send_join/send_leave/send_knock acceptance into a room WE are resident in — the same untrusted-input trust boundary as ingest_pdu_event, just a different endpoint) gets the identical state-before check, after-state recording, and current_state recompute. Fixed a separate latent bug found while wiring this: the callback never set event.prev_event_ids from the envelope at all, so every accepted membership PDU was stored as if it had no prev_events. invite_handler's invite-event store (kept only for a future send_join's auth-chain walk, never part of this server's own timeline) now gets explicit status = "outlier" instead of silently defaulting to "accepted".
  • Federated-join seeding (join_room/perform_federated_join, room_service.cpp): ingest_send_join_state now stores the response's critical state events with status = "outlier" and returns the full state map alongside the joined-members list (SendJoinStateIngestResult); the auth-chain persist loop gets the same outlier status. After the join event is stored, a snapshot state group is built from the returned state (database::create_or_reuse_state_group, no parent — a full snapshot, since we have no prior state-group history for this room), the join event gets an after-state group chained off that snapshot via record_event_state_with_parent, and recompute_current_state runs — so the join event becomes the room's sole forward extremity with a correct after-state, and the first inbound PDU after the join resolves normally instead of hitting missing_prev_state.
  • Source-tree guard test (tests/unit/test_store_event_choke_point.cpp): scans every .cpp under src/ (excluding src/database/, the store abstraction itself) and fails if database::store_event_with_state( appears anywhere outside a reviewed, counted allowlist — the choke point itself, and the handful of deliberate outlier/bespoke-bookkeeping exceptions listed above. A future path bypassing bookkeeping the way send_event and membership_acceptor did cannot land unnoticed again.
  • Bug found by the new tests and fixed: database::store_state's INSERT INTO state_transitions had no ON CONFLICT handling, but the table's primary key is (room_id, event_type, state_key, event_id) — not previous_event_id. State resolution making an event current again after it was already superseded once (exactly the "reverts to an older event" case the spec requires be delivered like any other state change) re-inserts that same primary key, which both SQLite and PostgreSQL reject as a duplicate row — commit_persistent_transaction silently failed and recompute_current_state's resolved winner was never written, current_state staying on whichever value the old direct-write path left there. Caught by delivery-order-independence tests where the two orderings diverged. Fixed: the write is now an upsert (ON CONFLICT (room_id, event_type, state_key, event_id) DO UPDATE SET previous_event_id = excluded.previous_event_id), matching the idiom already used elsewhere in the file, with the in-memory state_transition_index mirror updated to match (update in place on a reactivation rather than leaving the index pointing at a stale entry).
  • Tests: tests/unit/test_local_event_state_bookkeeping.cpp (the regression: a local send then a remote PDU referencing it must be accepted, not missing_prev_state; a local send after an inbound fork lists both tips as prev_events with a correctly resolved state-before; every room-creation initial event has a correct after-state group and the room's sole extremity is the last one), tests/integration/test_join_room_flow.cpp (a federated join's join event gets the returned state plus itself as its after-state, becomes the sole forward extremity, and a subsequent inbound PDU referencing it is accepted), and tests/unit/test_sync_handler.cpp (a fork whose resolution reactivates an already-superseded event is delivered to an incremental /sync as an ordinary state change — the scenario the state_transitions upsert fix exists for). All tags [pdu_ingestion][state_groups] (plus [sync][handler] for the last).

  • Phase B2 of spec-conformant PDU ingestion: the receipt-order checks themselves (ADR-0064, HIGH). Phase B1 made the state model correct; ingest_pdu_event still authorised every inbound PDU against current state only, treated any auth failure as a hard rejection, and rejected (instead of redacting) a content-hash mismatch. ingest_pdu_event (src/homeserver/local_http_router.cpp) now runs the spec's checks in order (server-server-api.md, "Checks performed on receipt of a PDU"):

  • Step 3 (hash). A mismatch no longer rejects the event — events::redact_event is applied and processing continues with the redacted form, which is what gets stored (content and the room version's other non-essential keys are stripped; event_id, sender, and the redaction survivor set are unaffected).
  • Step 4 (auth against the PDU's own auth_events). New validate_auth_events_selection enforces the spec's "Auth events selection" list (create, current power_levels, sender's own member event, and — for m.room.member — the target member, join_rules, third_party_invite, and restricted-join authorising member, each conditioned correctly on the requested membership): a named auth_events entry of a disallowed type, a duplicate (type, state_key), or one from a different room is a rejection; one this store cannot resolve at all is missing_prev_state (awaiting backfill), not a rejection. build_pdu_auth_event_map is generalised to build_auth_event_map_from_entries (an arbitrary flat state snapshot, not just store.state) so the same code builds the auth map from the named auth_events, the state before the event, and current state.
  • Step 5 (auth against the state before the event), using phase B1's compute_state_before.
  • Step 6 (auth against current state) — failure here is a soft failure, not a rejection, per spec.
  • Rejection and soft failure both now store the event (previously a step-4/5 failure was a hard rejection that never persisted it at all, breaking the spec requirement that later events referencing a rejected event can still be authorised). record_event_state/ record_event_state_with_parent (state_bookkeeping.hpp/.cpp) gained an accepted flag (default true): false still creates/reuses the event's after-state group and maps the event to it, but skips the forward-extremity update (spec: neither a rejected nor a soft-failed event is ever a forward extremity). A rejected event's after-state is state_before unchanged (spec: "not updating with the rejected event"); a soft-failed event's after-state is the normally-computed compute_state_after result (spec: "participate in state resolution as normal"), so a later accepted event chaining off a soft-failed state event's group correctly admits it into current_state — and, once admitted, it is served to clients through the ordinary state section, same as any other current-state entry. PersistentEvent::status (the "accepted"|"rejected"|"soft_failed"|"outlier" column phase A added but nothing ever set to the first two — see the security audit finding in the 0.12.12 entries below) is now set accordingly at ingest time; naive immediate store.state/membership-cache writes only ever happen for an accepted event.
  • New PduIngestionStatus::soft_failed, threaded through the worker IPC status mapping (src/homeserver/worker_pool.cpp, src/federation_worker/worker_event_loop.cpp) and the /send transaction per-PDU switch (src/federation/inbound_request.cpp): a transaction containing a mix of accepted, rejected, and soft-failed PDUs still returns 200 with per-PDU accounting (spec: a rejected event in a transaction "should not cause the transaction request to be responded to with an error response").
  • Client-delivery filtering. Every path that serves room timeline events now excludes rejected/soft_failed events, while state delivery (driven by current_state, untouched by this filter) still shows a soft-failed state event resolution has admitted: /sync timeline (client_server.cpp), MSC4186 sliding sync timeline (sync/sliding_sync_room_builder.cpp), GET .../messages (messages_json), GET .../context/{eventId} (room_context_json and its caller's target-event 404 gate), GET .../event/{eventId} (same 404 gate), and search (search_room_events_json's candidate scan and build_search_event_context). Federation-facing reads (/event/<id>, /backfill, /get_missing_events, src/federation/event_query.cpp) are intentionally unchanged: the spec allows /event/<id> to return a soft-failed event and only excludes soft-failed events from /backfill//get_missing_events when the request does not itself reference them, which those endpoints' existing depth/reference-driven scans already satisfy. Push notification delivery was already correctly gated (only an accepted PduIngestionResult reaches deliver_federation_push_notifications).
  • Deleted the dead state-conflict-resolver plumbing ADR-0064 identified as unreachable (nothing ever set PduIngestionResult::state_conflict, so state_conflict_resolver was never called): PduStateConflictContext, PduIngestionResult::state_conflict, StateConflictResolver, ResolvedStateApplier, runtime.federation.state_conflict_resolver, the resolver lambda in local_http_router.cpp, the branch in inbound_request.cpp's per-PDU switch that invoked it, and federation::apply_state_resolution_v2 (its one production caller was that same dead lambda). PduIngestionStatus::rejected_state_conflict is kept as a legacy enumerator (no production sink produces it any more) purely so the worker IPC wire format stays a stable, exhaustive set; resolve_state_v2 itself is untouched — phase B1's compute_state_before/recompute_current_state are its only callers now. The one test that exercised the deleted resolver (tests/unit/test_federation_membership_endpoints.cpp, "State-resolution v2 helper merges forked state when groups disagree") is removed with it.
  • v12 (MSC4291) state resolution fix found while testing this phase: resolve_state_v2's v2.1 iterative auth checks start from an empty running state (rooms/v12.md, "State resolution", modification 1) and no v12 event's own auth_events may name m.room.create (rule 3.2 — see the conformance test below). Together these meant build_auth_event_map_ from_state's create slot could never be filled for a v12 candidate by either its running-state lookup or its own-auth_events fallback: every v12 candidate's iterative auth check failed Step 2 ("room has no create event"), so no v12 room's state could ever be resolved at all once a real fork required iterative resolution — masked in existing tests only because their hand-built fixtures happened to name create in auth_events (itself a separate, now-fixed rule-3.2 violation). Fixed by seeding resolved's m.room.create entry from unconflicted at v2.1 initialization: a room's create event cannot genuinely be in dispute (exactly one per room, agreed by every submitted state group — that agreement is why it is in unconflicted, never conflicted), so this does not reintroduce anything modification 1 exists to guard against. See docs/event-engine.md, "Phase B1: state resolution wired into ingestion" (auth difference section).
  • Tests: tests/unit/test_pdu_ingestion_auth_checks.cpp ([pdu_ingestion][auth]) — a bad-hash PDU redacted and accepted; a PDU passing current-state auth but failing its own auth_events, rejected; one passing auth_events but failing state-before, rejected; the ban-evasion case (prev_events preceding the ban pass both auth_events and state-before but fail current state) soft-failed rather than hard-rejected; a rejected event's after-state equal to its state-before with a later event still able to reference it; a soft-failed event's after-state includes itself (distinguishing it from a rejected event's, which does not — whether any particular fork resolves in a soft-failed candidate's favour is a state-res v2 question covered by test_state_resolution_auth_diff.cpp/test_state_resolution_conformance.cpp, and a banned sender's soft-failed event correctly does NOT win a real resolution either, per spec "Soft failure"'s own anti-injection guarantee); the three auth_events-selection violations; and a mixed-outcome transaction. Plus new tests/conformance/test_pdu_ingestion_conformance.cpp ([conformance][federation][auth][room-v12]): a v12 PDU naming m.room.create in auth_events is rejected (rooms/v12.md rule 3.2), and the same PDU with create correctly omitted is accepted. See ADR-0064.
  • Not done in this phase: the membership-acceptor path (send_join/send_leave/send_knock acceptance, src/federation/inbound_request.cpp's membership handler) still hard- rejects a content-hash mismatch (403) rather than redacting, and does not run the auth_events/state-before/current-state three-way check — it predates ADR-0064 and is a materially different trust boundary (a single trusted join response, not a transaction of arbitrary PDUs). Tracked as a follow-up, not silently left inconsistent.

0.12.12

Documentation only — no code changes beyond the version bump. A full audit of every project document against the code found and corrected stale or wrong statements, and filled the gaps agents and operators were tripping over.

Agent guides (AGENTS.md)

  • Root AGENTS.md: the project layout omitted five modules (appservice, federation_worker, identity, ipc, push) and two test directories; the key entry points named files that do not exist (src/homeserver/main.cpp, homeserver/server.hpp) and omitted three of the four executables; the glossary pointed at wrong headers for power levels, auth rules and room versions. The project overview is reworded.
  • New module guides for src/federation_worker/, src/ipc/ and src/push/, plus the missing CLAUDE.md shims for those and for src/appservice/ and src/identity/, whose guides were never loaded.
  • Corrected module guides: auth (UIAA state is in memory, not an auth_sessions table; token hashing is BLAKE2b, keyed for v3/v4), config (the reload-restart list was backwards for rate limits and log modules), crypto (libsodium boundary matches reject-unsafe.sh; nine undocumented files), http (three files mapped to the wrong responsibility), net, media (upload default is 50 MiB), homeserver (twelve undocumented files; the upload pipe format described a fixed bug), federation, platform (an ELF-probe failure blocks start-up, it does not warn), rooms (the server does not reject plaintext in encrypted rooms), sync, trust_safety, observability, events, and the test, script and fixture guides.
  • docs/AGENTS.md now lists which documents to update for config, logging, dependency, endpoint and new-module changes.

Operator and design documents

  • user-manual.md: eight wrong config defaults, an incomplete reload-policy table, missing exit codes 80 and 81, and a new Admin API section.
  • observability-audit.md: federation audit events are held in memory only and never reach audit_log or GET /_merovingian/admin/audit; this is now stated, and the audit catalogue is complete.
  • security-review-checklist.md, security-coding-rules.md and threat-model.md now cover the federation-worker IPC channel, Application Service API, Identity Service client, push gateway delivery and cross-signing over federation; the checklist no longer claims remote media fetch is disabled.
  • Stale "not implemented" claims removed across auth-identity.md, canonical-json.md, crypto-boundary.md, http-transport.md, event-engine.md, database-persistence.md, the dependency notes and the capability trackers; wrong facts corrected in architecture.md, build-warning-policy.md, release-process.md, log-filtering.md, trust-safety.md, coding-rules.md and README.md.
  • Seventeen broken links into the local Matrix spec copy fixed.

Gaps now recorded in docs/todos/capability-gaps.md

  • No soft-fail check on inbound PDUs.
  • Room versions 1 and 2 are registered, but their event-ID format is not implemented.
  • User reporting (POST /users/{userId}/report) and redaction (PUT /rooms/{roomId}/redact/...) are not routed.
  • Three admin routes are declared but unreachable, and http::Server is dead code.
  • Thirteen further v1.19 Client-Server operations are not routed (among them admin/whois, URL previews, password-reset requestToken, login/get_token and timestamp_to_event). matrix-v1.19-client-server-api.md and its generator now state that the file is a spec reference, not a status tracker.

0.12.11

  • Fix federated room joins failing with HTTP 500 event signing failed when the resident server's make_join template already contains hashes. Replace the template's hashes with the freshly calculated content hash before signing, rather than adding a duplicate JSON member. Apply the same correction to federated leave templates.
  • Add regression coverage for membership templates with and without existing hashes, checking the outgoing content hash and local event signature.

0.12.10

Fixes devices of users on other servers always showing as unverified, even when their owners had verified them. A client decides a device is verified when its owner's self-signing key has signed it, and a user is verified when the viewer's own user-signing key has signed that user's master key. Every link in that chain was being lost between servers, in both directions.

Your users looking at remote users

  • The /keys/query federation proxy threw away remote cross-signing keys. It copied only device_keys from the remote /user/keys/query response and discarded master_keys and self_signing_keys, so a client could never check a remote device against its owner. They are now passed through.
  • Your own verification of a remote user never stuck. The user-signing signature a client uploads over a remote user's master key is stored here, never on the remote server. It is now merged back into that master key for the user who made it, and for nobody else (spec: signatures "that the requesting user is allowed to see").
  • The proxy trusted whatever the remote server said. Any user ID in the response, on any server, was passed to the client. A malicious server could have injected devices, and after this fix a master key, for users it does not own. federation::accept_remote_key_query_response() now keeps only the users that were asked about, and only keys that describe the user they are filed under.
  • Inbound m.signing_key_update was dropped as an unknown EDU type, so a remote user resetting their cross-signing never reached local clients. It now puts the user in device_lists.changed, as m.device_list_update does.

Remote users looking at your users

  • Keys served over federation carried no uploaded signatures. /user/keys/query and /user/devices/{userId} sent device keys without the owner's self-signing signature, so remote servers saw every device here as not verified by its owner.
  • Outbound m.device_list_update carried unsigned keys. Remote servers such as Synapse apply the EDU's keys directly to their cache when prev_id is empty, so they cached the unsigned form.
  • m.signing_key_update was never sent. Remote servers that serve /keys/query from their cache never learned your users' cross-signing keys. Uploading a master or self-signing key now sends it to every server the user shares a room with. An upload of only the private user-signing key sends nothing.

Both directions

  • Signatures on cross-signing keys were never found. /keys/signatures/upload addresses a cross-signing key by its unpadded base64 public key, as in the spec's request example. The lookup used ed25519:<key>, so signatures on master keys, including every user-signing signature, were stored but never returned. The conformance scenario "POST /keys/signatures/upload then POST /keys/query returns the uploaded signatures" had uploaded with the prefixed form, which is not the spec's and is not what clients send. Its request body now uses the spec form; its assertions are unchanged.
  • Signature visibility is now defined and applied everywhere. All paths that serve keys go through federation/key_signatures.hpp. An upload by the key's owner is public. Any other upload is visible only to its uploader. Only the uploader's own signer entry is ever merged, so no upload can publish a signature under another user's name. Before, every stored signature was shown to every local requester. ADR-0060.
  • Remote users' keys are proxied live and never cached; inbound m.signing_key_update is a change signal only. ADR-0061.

Tests

  • New tests/unit/test_federation_key_signatures.cpp: key-ID form, owner and viewer visibility, forged signer entries.
  • tests/unit/test_federation_key_query.cpp: signatures in /user/keys/query and /user/devices; remote response filtering (injection, mismatched user_id/device_id/usage, non-object bodies); m.signing_key_update and m.device_list_update content.
  • tests/unit/test_inbound_ingestion.cpp: m.signing_key_update classification and content validation.
  • New tests/integration/test_federation_cross_signing_flow.cpp: the proxy against a real TLS peer (remote cross-signing keys returned, the requester's own signature merged, injected users dropped, and a third user not seeing the signature); m.signing_key_update sent on upload, not on a USK-only upload; inbound m.signing_key_update reaching /sync, and rejected from the wrong origin.
  • tests/conformance/test_client_server_conformance.cpp: a user-signing signature over a master key is shown only to its signer.

0.12.9

Fixes all 33 confirmed findings of the September 2026 security audit (docs/security-audit-report-2026-09.md). Every finding was checked against the code and against the v1.19 spec before any change was made. Three did not survive that check as stated and were implemented differently; each is called out below, because in every case the audit's suggested remedy would have looked complete while being wrong.

High severity

  • H-01 POST /_matrix/client/v3/refresh authenticates with a refresh token, so it never reached the access-token moderation gate. A locked account could keep minting access tokens indefinitely, defeating M_USER_LOCKED on every endpoint except logout. refresh_local_session now refuses a locked account with 401 M_USER_LOCKED and soft_logout: true.

Departure from the audit: suspended accounts are deliberately still served. The spec SHOULD-lists logging in, creating further sessions, and reading through /sync and /messages among a suspended account's permitted actions, and this server's own suspension allowlist already permits POST /login — which mints unlimited fresh access tokens. Refusing a rotation while permitting a fresh login buys nothing and denies the reads the spec asks servers to preserve. See ADR-0058.

  • H-02 Refresh no longer resurrects a deleted device. The token lifecycle binds every credential to a device row; a refresh token that outlives its row can only have arrived through a failure, and honouring it reanimated a session for a device the user believed was gone.

  • H-03 Accepted plain-HTTP sockets were left blocking. Reads were guarded by the HTTP layer's poll(POLLIN), but the response write was not, so a peer that accepted a connection and then stopped reading parked a worker inside ::send() indefinitely — one client could exhaust the pool. ADR-0054 fixed this for TLS and stated the rule; plaintext was left out. Accepted sockets now carry SOCK_NONBLOCK from accept4 itself, and both send paths wait for writability against a deadline.

  • H-04 Per-PDU rejections incremented a local copy of the peer's consecutive-failure count, and the handler then zeroed it unconditionally at transaction completion. A peer could send endless transactions of forged PDUs — each answered 200 with per-PDU errors — without ever tripping the backoff or circuit breaker. The count now survives any transaction in which a PDU failed a trust check; only a fully clean transaction resets it. Room-ACL denials are excluded, because an ACL is local policy rather than evidence of peer misbehaviour.

  • H-05 The event signer logged the full canonical signing payload and the complete signed event JSON at debug level under field names the redactor does not recognise. Diagnostics now carry each payload's byte count and SHA-256 digest only — still enough to compare byte-for-byte with a peer when triaging a BadSignatureError.

  • H-06 V2 state resolution never populated authorising_user_member, which the restricted-join authorization branch requires, so every valid restricted join in the conflicted set was rejected and room state diverged across servers.

  • H-07 V2 state resolution read power levels with an integer-only accessor. Room versions 1–9 permit string-encoded levels and the authorization path already parses them, so the resolver silently demoted such senders to users_default — letting a lower-power event win the ordering. It now respects the room version policy, matching the auth path.

Medium severity

  • M-01 The Argon2id hash of the registration token file was cached by path and never revalidated, so rotating the token on disk kept the old one live until restart. The cache is now keyed on the file version (device, inode, size, sub-second mtime) and only stores an entry when the file did not change between the two stats bracketing the read.
  • M-02 The rate limiter's own bucket tables used std::hash<std::string> over attacker-influenceable keys, so one precomputed collision set degrades every deployment's table into a linear scan. Keys now hash through keyed BLAKE2b at the crypto boundary under a per-process random key.
  • M-03 Parser errors, timeouts and head-too-large responses carried no CORS headers, so a browser saw them as CORS failures instead of the real status (spec §10.5). The Origin is now read out of the raw head, which is the only source available when the head never parsed.
  • M-04 v1 invites fell back to room version 12 for the reference-hash event ID and the Ed25519 check. The spec requires a v1 invite to be assumed room version 1 or 2; the room's real version is now resolved from local state as send_join does, falling back to v1.
  • M-05 verify_inbound_federation_signature is exported and skipped the Ed25519 check whenever a caller-set bool said it was already verified. It now always verifies; the worker fast path stays in handle_inbound_federation_request.
  • M-06 The well-known discovery overload resolved through a literal network that returns a documentation IP for every host, bypassing DNS and the SSRF checks. It now requires an explicit test-only opt-in token, so a production call site written against the old signature fails to compile.
  • M-07 / L-07 RuntimeEd25519Provider handed its stored SecretBuffer straight to libsodium with no size check. The constructor is the trust boundary where forgery-capable material enters, so a secret that is not exactly 64 bytes is now never retained, and sign() keeps its own guard.
  • M-08 The identity-server unbind credential pair (client_secret, sid) was bound as a public value and could reach a query trace or diagnostic log. Both are now sensitive bindings.

Scope note: the audit also asked for encryption at rest or a redesign around homeserver-signed unbind. That is a schema and protocol change, not a binding fix, and remains the documented residual risk in docs/threat-model.md. - M-09 PQexecParams sends every parameter as a null-terminated C string, so a raw binary payload truncates at its first embedded NUL. media_blobs.bytes is now marked binary and hex-encoded into PostgreSQL's bytea literal on the way in and decoded on the way out, matching SQLite's length-based binding. - M-10 Neither backend serialised a migration plan across processes, and each step commits separately, so two servers starting together could interleave DDL. PostgreSQL now holds a pg_advisory_lock for the whole plan under an RAII lease. SQLite cannot hold a cross-process lock spanning its per-step transactions without deadlocking against them, so it re-reads the ledger inside each step's BEGIN IMMEDIATE and skips a step the winner already applied. See ADR-0059. - M-11 LOG_* passed a caller-built string straight to SingleLog, bypassing the only secret-redaction boundary the structured diagnostics use. Every named method now routes its composed line through redact_log_message, which applies the same sensitivity rule as structured fields.

Low severity

  • L-01 / L-02 Both UIAA challenges carried a compile-time constant session id shared by every client and every attempt. Challenges now mint a random per-attempt id, bounded and TTL-pruned, scoped to the endpoint that issued it; a client that supplies a session must supply one we issued for that endpoint. Omitting it entirely is still served, because the spec permits completing a single-stage flow in one shot. Multi-stage tracking is deliberately not implemented — there is exactly one configured stage, so there is no ordering to enforce. See ADR-0057.
  • L-03 device_id_is_valid accepted : — the key-ID separator, which makes ed25519:a:b ambiguous — along with /, ? and #, which break the /devices/{deviceId} path segment.
  • L-04 The TLS 1.2-and-below cipher list still permitted plain-RSA key exchange, so a later compromise of the server's key could retrospectively decrypt recorded traffic. Restricted to authenticated ephemeral AEAD suites; TLS 1.3 ciphersuites are configured separately and are unaffected.
  • L-05 apply_cors_headers paired Access-Control-Allow-Credentials with a wildcard origin, which CORS forbids.

Correction to the audit: this was not reachable through configuration — config::validate already refuses wildcard-plus-credentials and the server will not start with it. The fix is defence in depth for a runtime CORS snapshot that never passed through validation. - L-06 The backfill limit reached the injected provider unclamped; it is now capped at 100. - L-08 parse_u32 in the db-migrate CLI folded empty input, non-numeric text and overflow all into 0, so --plan abc def printed an empty plan and exited 0. - L-09 The audit sink was a plain function pointer written during thread startup and read from arbitrary threads with no happens-before edge. It is now a lock-free atomic. - L-10 local_audit_sink no-opped silently whenever the thread-local database was unset or closed. It now warns once per drop episode rather than once per dropped event, which would have made a flood worse. - L-11 AuditLogEvent.append_only asserted a guarantee nothing enforced. Removed rather than left lying; real enforcement is a database trigger, which belongs in its own migration. - L-12 string_format forwarded a runtime string as a printf format (CWE-134). It and the LOGF_* macros built on it had no call site in src/, so both are deleted outright. - L-13 The bounded console and file log queues discarded messages with no signal. They now count drops and emit one warning per episode, written straight to stderr so the warning about a full queue is not itself queued. - L-14 signing_key_id_is_valid accepted any printable key id; it now delegates the ed25519: prefix and shape check to the crypto boundary. - L-15 make_content_hash_id hardcoded room version 12 regardless of the event's actual version; it now takes the policy from the caller.

Found while reviewing the suspension allowlist

  • Account suspension was bypassable through a client-chosen path segment. Not an audit finding — found while answering a question about what suspended accounts may still do. action_allowed_while_suspended permitted its three per-room actions by searching for /leave, /messages and /redact anywhere in the request path, and the gate sees the raw undecoded target. Several room endpoints end in a segment the client picks: the transaction ID of PUT /rooms/{roomId}/send/{eventType}/{txnId} and the state key of PUT /rooms/{roomId}/state/{eventType}/{stateKey}. A suspended user who named their transaction redact could therefore send arbitrary messages, and one who named a state key redact could write room state — both returning 200, defeating suspension for the two actions it most exists to block.

The allowlist now matches the action segment — the path segment immediately after the room ID — by exact comparison. Anything added to this gate in future must be anchored to a segment the client cannot choose.

Note also that the gate cannot enforce the spec's "redact their own events", because it cannot see the target event's sender. That endpoint is not routed today (404 M_UNRECOGNIZED); whoever implements it owns the ownership check, and the comment at the gate says so.

  • The suspension allowlist admitted more key endpoints than the spec permits. Also not an audit finding. The spec's permitted-actions list names two key-related capabilities — "Verify other devices and write associated cross-signing data" (§Device verification, §Cross-signing) and "Populate their key backup" (§Server-side key backups). Between them those sections name exactly POST /keys/query, POST /keys/device_signing/upload, POST /keys/signatures/upload, and the /room_keys/ subtree.

The gate admitted the whole /keys prefix, which additionally allowed POST /keys/upload (publishing the account's own device and one-time keys), POST /keys/claim (claiming a one-time key to open an Olm session) and GET /keys/changes. The first two are participation rather than verification. The permitted endpoints are now listed exactly, with their methods, and /room_keys gained a trailing slash so the prefix cannot match a longer path segment.

Also fixed

  • Three defects the branch's own first verification run surfaced in its new tests: a dangling pointer into a parse_object temporary, a CORS test whose config could never start, and a race between accept and the worker's first I/O.

0.12.8

  • Publish parallel security audit report covering 33 confirmed findings across auth, client HTTP, federation, crypto, database, observability, and event engine surfaces, with adversarial verification and actionable acceptance criteria (see docs/security-audit-report-2026-09.md).
  • Apply configured module/default log levels to direct logging macros as well as structured diagnostics, including when console debugging is enabled.
  • Emit one diagnostic warning per client rate-limit rejection while preserving both policy audit records and the HTTP 429 response with retry information.
  • Serve the Matrix v1.19 federation version endpoint without authentication, avoiding spurious authorization warnings for ordinary version discovery.
  • Raise the built-in thumbnail-only rate limit from 20 to 60 requests per minute for normal client rendering bursts, while retaining existing upload and full-download limits, and document scoped operator tuning.

0.12.7

Fixes the eight findings of the 0.12.6 security audit. All eight were verified against the code and against the v1.19 spec before any change was made; none were misreads.

Two of them are only meaningful together, and two of them were not what the audit's suggested remedy would have produced. Both cases are called out below, because in each the obvious fix would have looked complete while leaving the hole open.

M-01 (critical): federation membership endpoints persisted state without authorizing it

send_join, send_leave and send_knock verified the inbound PDU's Ed25519 signature, its content hash, and its sender/origin consistency — and then persisted the event and the membership row after checking only that the room existed. Signature verification establishes who signed an event; it never establishes whether they are permitted to make the transition. Any remote server holding a valid signing key could therefore join a user into an invite-only room, or move a membership it had no power level to move, simply by presenting a correctly signed PDU.

The ordinary /send transaction path already got this right: ingest_pdu_event builds an auth-event map and runs authorize_event_against_auth_events before persisting. The membership acceptor now runs the identical gate, before anything reaches the store. A rule enforced on only one of two paths into the same store is not enforced at all.

M-02: SRV delegation moved the TLS identity to the SRV target

Spec §Resolving server names, steps 4 and 5: "Requests are made to the resolved IP address and port, with a Host header of <hostname>. The target server must present a valid certificate for <hostname>." <hostname> is the original server name — the spec follows those steps with a section explaining precisely why the SRV target must not be used instead.

ServerDiscoveryResult had no field distinguishing "where the packets go" from "whose certificate must be presented", so every outbound URL was built from resolved_host — the SRV target. SRV records are unsigned DNS, so anyone able to forge a DNS response could redirect a homeserver to a host presenting a certificate for a name it legitimately controls, and be trusted as the origin server. That includes /_matrix/key/v2/server, the root of trust for every signature that server ever makes.

ServerDiscoveryResult::tls_server_name now carries the certificate identity — the original hostname for the SRV and direct paths, the delegated hostname under well-known delegation, never the SRV target. Outbound URLs are built from it, while pinned_addresses continues to route the connection to the discovered target via CURLOPT_RESOLVE, so SNI, Host and certificate validation all sit on the authoritative name. resolve_destination takes the certificate host as a required parameter rather than deriving it, so a future discovery branch cannot silently inherit the wrong one. All three production URL builders were affected, not only the key cache cited in the finding.

M-03: named room-state reads were not authorized at all

GET /rooms/{roomId}/state/{eventType}/{stateKey} checked that the room existed and then returned the state event's content to any authenticated user. The two sibling handlers directly above it in the same file both perform a proper membership check; this one did not. Its 403 text ("not a member of this room") was also returned for a room that simply did not exist.

Per the spec, a joined member now reads current state, and a user who has left reads the state as it stood at their leave point — not the current value, which would leak everything written after they left. Everyone else, including a user with no membership row and a room that does not exist, receives an identical 403, so a non-member cannot distinguish the two. A ban denies access outright: the spec sentence names "joined" and "has left", and a banned user should not get more than the full-state endpoint gives anyone who is not currently joined.

M-04: logout left the device's refresh token usable

Single-device logout revoked the access token and flipped the in-memory session, but never touched the refresh token. refresh_local_session admits any refresh row that is merely unrevoked, so the client that had just logged out — or anyone holding a stolen refresh token — could immediately mint a fresh access token. Logout was cosmetic. logout_all, device deletion and password change all revoked refresh tokens already; single-device logout was the one path that did not, and now does.

M-05: password change resurrected previously revoked tokens

With logout_devices (the default), the password-change flow revoked every token for the user and then restored the caller's own device with an unfiltered UPDATE access_tokens SET revoked = false WHERE user_id AND device_id.

That statement cannot distinguish a token it revoked microseconds earlier from one revoked days earlier by a logout, an admin action, or a previous password change. It un-revoked all of them. A password change is the action a user takes after discovering a compromise, and it handed the attacker's revoked token back.

The revoke-then-restore pair is replaced by revoke_tokens_for_user_except_device, which never touches the caller's device, so there is nothing to reinstate. restore_tokens_for_device is deleted outright: no code path can now clear a revoked flag. See ADR-0052 — revocation is one-way, and that is a rule for future code, not just a fix here.

M-06 and M-07: request-body and TLS read deadlines (these two only work together)

M-06: request-head reads enforced both an overall deadline and an inter-byte cap. Request-body reads enforced neither — only a fresh 15-second poll per 4096-byte chunk, so a client dribbling a declared Content-Length could hold a worker thread for roughly (bytes / chunk) x 15s without ever timing out. The body now carries both caps, with the overall deadline scaled by the declared length at a 16 KiB/s floor so that large media uploads are unaffected while a dribbled 1 MiB body is cut at about 94 seconds instead of 65 minutes.

M-07: TLS sockets were restored to blocking mode after the handshake, and TlsConnection::read called SSL_read_ex on them. The HTTP layer's poll(POLLIN) proves that TCP bytes are available — never that a complete TLS record is. A peer sending one byte of a record made the socket readable, entered SSL_read_ex, and blocked in the kernel indefinitely: past the poll deadline, past the head deadline, outside every timeout the server believed it was enforcing. One connection could park one worker thread for as long as the attacker liked.

Sockets now stay non-blocking for the life of the connection, and one shared pump() drives WANT_READ/WANT_WRITE for both reads and writes against a deadline. M-06 is inert on TLS listeners without M-07, since a body deadline means nothing while a single read beneath it can block forever — which is why they ship together. See ADR-0054.

A cap that could never fire. Writing the socket-level cover for these two turned up a defect in the M-06 fix itself. The slowloris caps are only re-checked between reads, but recv_with_timeout always polled the full 15-second per-read window — so one poll outlived the 5-second inter-byte cap and that cap could never fire. A client stalling mid-body was still released at 15 seconds, exactly the pre-fix timing. The overall deadline worked; the minimum-progress half did not.

recv_with_timeout now takes a poll budget, and both read loops pass the smallest of the per-read timeout, the overall deadline, and the remaining inter-byte allowance. Budget expiry is reported distinctly from a peer close, so the loop re-checks and the cap that actually expired is what ends the request and gets logged.

This applies to the request head read as well, which had the identical structure. Its comment claimed the caps bound the worst-case worker hold time "regardless of how cleverly the client dribbles bytes" — they did not. Fixing only the body would have left a provably inert cap beside the one being repaired.

M-08: the thumbnail decoder ran under the general server syscall filter

The thumbnail worker exists so that a libpng or libjpeg-turbo memory-safety bug is contained rather than fatal. OpenBSD confines it with pledge("stdio") and FreeBSD with cap_enter() — neither grants filesystem, socket or process-creation access. On Linux it installed apply_seccomp_filter(), the general server allowlist, which permits socket, connect, sendto, openat, unlinkat, execve and clone. On the primary production platform the decoder was effectively unconfined, while the file's own comment claimed a decoder exploit had "nothing to reach for".

The existing stricter apply_worker_seccomp_filter() is not a substitute, and using it would have looked like a fix while changing almost nothing: it was built for the federation worker, removes only execve/execveat, and still permits sockets, openat and clone. A new decoder profile now matches the pledge("stdio") boundary, so all three platforms confine the decoder equivalently. A regression test asserts the decoder profile is strictly tighter than the worker profile, so a later "simplification" onto the shared filter fails the suite instead of silently reopening this. See ADR-0053.

Decisions recorded

  • ADR-0052 — revocation of credentials is one-way; no database function may clear a revoked flag.
  • ADR-0053 — sandbox profiles are named for the threat they contain, not for the kind of process that installs them.
  • ADR-0054 — TLS sockets stay non-blocking for the life of the connection; nothing below the HTTP layer may perform a blocking I/O call on a connection descriptor.

0.12.6

Closes issue #487: the hand-written guard.unlock(); f(); guard.lock(); pattern around HomeserverRuntime::mutex is gone, and so is the defect class behind it.

A third instance of the recursive-release stall, found and fixed

runtime.mutex is recursive so that service functions such as create_room, join_room, leave_room and invite_user_by_threepid stay independently callable. When one of them runs beneath a request handler that already holds its own guard, unlock() drops one recursion level and not the lock, so the mutex stays held for the whole of the "released" network call and every other client request and inbound federation transaction stalls behind it.

That had already shipped twice (create_room in 0.12.1, leave_room in 0.12.3). Writing the regression test for #487 first turned up a third, still live: invite_user_by_threepid released only its own guard around the identity-server /store-invite round trip. An ordinary authenticated client inviting an email address through an operator-trusted identity server froze the whole server for the length of that call. The new scenario in tests/integration/test_request_lock_contention_flow.cpp drives a real TLS identity server that accepts and then withholds; against the old code an unrelated request took 8001 ms against a 3000 ms budget.

One release primitive that cannot be misused

  • HomeserverRuntime::mutex is now a homeserver::RuntimeMutex — a std::recursive_mutex that also records its owning thread, so a release scope can ask whether the calling thread still holds it. std::recursive_mutex cannot answer that, which is why the old mechanism could not be made safe.
  • NetworkIoUnlock and ScopedGuardRelease collapse into homeserver::RuntimeLockRelease. The two differed in which guard they acted on — the thread's published one versus one in hand — and at a nested call site those are different objects. Picking the wrong one compiled, passed its tests, and held a global mutex across a network round trip; that distinction is exactly what made the leave_room fix incomplete. The two constructors still name the choice, but both release every recursion level the calling thread holds and restore exactly that many on scope exit, throwing paths included.
  • Releasing a caller's guard before delegating to a self-locking function is no longer load-bearing. The existing call sites that do are kept because they are explicit, not because correctness depends on them.

join_room's released region is now a function

join_room's release spanned roughly 350 lines, and nine of the forty-four values declared inside it were consumed after the re-lock. A mechanical conversion would have hoisted those nine above the scope: const stripped from each, every type forced to be default-constructible, nine initialisations split into declare-then-assign inside a 1000-line function. That is why it had been deferred.

It is now extracted into perform_federated_join, returning a FederatedJoinOutcome that carries exactly those nine values plus an optional failure the caller returns verbatim. The lock boundary is the function boundary, nothing needs hoisting, and the release is a two-line scope around the call. invite_user_by_threepid and the three local_http_router.cpp sites use the same idea at smaller scale, with an immediately-invoked lambda.

Everything else converted

  • local_http_router.cpp: the persistent-store commit release, the createRoom delegation, and the join delegation are all RuntimeLockRelease scopes. Each had an early return or a self-locking call inside the released region that a throw would have stranded.
  • No hand-written release of runtime.mutex remains in include/ or src/. The two unlock() calls that survive the gate are the make_join race's notify-outside-the-lock idiom on its own local mutex.
  • scripts/reject-unsafe.sh now matches ->unlock() as well as .unlock() — a release through a pointer used to slip past unseen — and exempts by name the two files that implement the primitives.

Fixed in review on #490

Four findings from review, all in code this branch introduced:

  • Nested release scopes corrupted the recursion depth. The scope released the named guard through unique_lock::unlock() and drained the remaining levels through the mutex. Draining cannot clear another unique_lock's ownership flag, so an inner scope reaching for the thread's published guard saw a stale owns_lock() == true, unlocked a mutex the thread no longer held, underflowed the depth, and would have stranded runtime.mutex locked for the life of the process. The scope now drops every level through the mutex and never consults a guard's flag; nested scopes read held_by_current_thread() and correctly find nothing to release. The new scenario fails against the old shape with Operation not permitted.
  • perform_federated_join captured a dangling key_id. Extracting the function turned key_id into a std::string_view, so auto key_id_copy = key_id; deduced a view rather than the owning std::string the surrounding comment promises. make_join tasks that lose the race are moved into runtime.orphan_futures_ and outlive join_room's frame, so a slow or semaphore-queued candidate could read freed memory when building its X-Matrix authorization header.
  • FederatedJoinOutcome held the room-version policy as a raw pointer, making the caller's dereference depend on a separate failure field and breaking the project's no-raw-pointers rule. It is held by value now; the background-fill task already copied it.
  • The reject-unsafe gate exempted two files wholesale, which would have let any future unannotated release in either primitive through, and scripts/AGENTS.md says not to add exceptions to that script. The exemption is per-line again, with the same LOCK_RELEASE: reviewed annotation every other site would need.

Architecture Decision Records introduced

docs/adr/ is new: Architecture Decision Records in the MADR 2.1.2 format, following the layout and template of the Opinionated Digital Center's ADR repository — docs/adr/NNNN-title-in-lowercase-with-dashes.md, a table of contents in docs/adr/index.md, the template in docs/adr/template.md, and .adr-dir at the repository root so adr-tools and pyadr find the directory. Status and date are mandatory.

Fifty-two records. ADR-0000 and ADR-0001 record the practice and the format; ADR-0002 to ADR-0008 record this branch's decisions; ADR-0009 to ADR-0051 are backfilled from existing documentation and code, covering concurrency, canonical JSON, crypto and secret handling, transport and rate limiting, media, appservices, sync, database roles, platform support, and process. Each carries a note that its date is the date of the record, not of the decision.

Backfilling turned up one documentation defect: docs/event-engine.md stated that signing keys are regenerated on every restart, "effecting automatic key rotation". They are not — find_active_server_signing_key selects the stored key and republishes a lapsed valid_until_ts window rather than replacing it, and silent regeneration is prohibited. Corrected, and recorded as ADR-0017.

Two exist because the code depends on them without saying so locally, which is the case the format earns its keep for:

  • ADR-0004 — for the lifetime of a release scope, every std::unique_lock on runtime.mutex reports owns_lock() == true while the mutex is actually free. That is sound only because nothing outside request_lock.cpp reads the flag. Introducing such a read anywhere else breaks it, and no compiler will say so.
  • ADR-0003 — a callee's release scope now drops a lock its caller was holding. The blast radius was checked once (ingest_pdu_event is the only candidate site, and neither caller holds a level); a new caller of it that holds runtime.mutex reopens the question.

docs/AGENTS.md otherwise forbids new documents; adding an ADR is now the one stated exception, since the format's value comes from an immutable file per decision. Registered in docs/AGENTS.md and the root AGENTS.md key-docs list, and the root AGENTS.md General Rules now carry the standing rule: record a decision in the register whenever you make one, with the test for what counts as a decision and the requirement that entries are never renumbered or deleted.

Tests

  • tests/unit/test_request_lock.cpp rewritten to assert on what another thread can observe, not on what a guard believes. A recursive mutex answers try_lock() with "yes" to its own owner at any depth, so only an outside probe distinguishes "released" from "one level released". New scenarios cover a nested dispatcher/service pair, the published-guard constructor over an unpublished nested guard, the throwing path in both, and RuntimeMutex ownership reporting.
  • tests/unit/test_request_lock.cpp also covers scope nesting: an inner release scope opening inside an outer one, and a fresh lock taken inside a released region and then released again. Both assert the depth is restored exactly once per level by unwinding the guards and checking ownership ends.
  • tests/integration/test_request_lock_contention_flow.cpp gains the third-party-invite scenario described above.

0.12.5

Fixes for the security audit recorded in security/audit-findings-2026-09-04.md. The findings file itself was added earlier on this branch; this entry covers the code changes.

Signing-secret lifecycle (findings 1-7, 16, 20)

  • The plaintext server signing-secret fallback is gone. A server with no security.secrets.master_key_file used to persist its Ed25519 seed as base64 with encrypted='false', so anyone who exfiltrated the database obtained a forgery-capable federation signing key — the exact threat that column exists to defend against. Both first generation and rotation now fail closed. The audit cited only the generation path; the rotation path carried the same fallback and was fixed with it.
  • A master key is now required to start. With no signing key a server cannot sign an event or a federation request, so start_runtime refuses to start and says why, rather than surfacing three steps later as a failure to derive an unrelated pagination-token key. Legacy plaintext rows are still read, so an existing deployment keeps federating and can rotate on its own schedule.
  • An unlockable master key is refused. load_master_key_material() tolerated a failed sodium_mlock() and returned the root secret in swappable memory after a one-time warning. RLIMIT_MEMLOCK exhaustion therefore silently downgraded every key derived from it. This reverses the #487 decision to keep the condition non-fatal: an unlocked root secret is a crypto-boundary failure, and the remedy (raise RLIMIT_MEMLOCK, or grant CAP_IPC_LOCK) is a deployment change.
  • Ed25519 secrets live in core::SecretBuffer end to end. Ed25519Keypair, RuntimeEd25519Provider, RuntimeMultiKeyEd25519Provider and the return of secret_box_decrypt all held 64-byte seeds in plain std::array/std::vector — unlocked, never zeroised, and in the provider's case for the whole life of the process. crypto_sign_keypair now generates straight into the locked buffer and crypto_secretbox_open_easy decrypts straight into one, so no intermediate unprotected copy exists at any point. rebuild_signing_provider no longer stages every active secret through a std::vector of plain arrays.
  • The signing-secret box key is cached. Encrypt and decrypt each re-read the master key file and re-derived the box key on every call, re-materialising the root secret and churning a 4 KiB mlock/munlock pair per operation. It is now cached against the master key file's identity, the same way #487 fixed the access-token HMAC keys; the identity helper is shared between the two.
  • The master key file is read unbuffered. std::filebuf kept its own copy of every byte read in ordinary heap memory that is freed without zeroisation, so wiping our own read buffer still left plaintext root-secret bytes in the process.
  • The stored signing-key row wipes its own secret (finding 17). PersistentServerSigningKey.secret_key is a std::string, so the in-memory store held the signing secret's at-rest form in heap memory freed without being wiped, and every copy or move left another unwiped buffer behind. The field cannot become a core::SecretBuffer without making the row move-only, which the store's row vectors and query paths depend on it not being, so the row now zeroises the field on destruction, on move, and before an assignment overwrites it. This is a mitigation, not SecretBuffer's guarantee — the bytes are still swappable while live. What protects them at rest is that since finding 1 they are always ciphertext.

Unbounded resources reachable from the network (findings 11-13, 19)

  • The ThreadPool work queue is bounded. Both accept loops queue one closure per accepted connection into an unbounded std::queue, so a connection flood grew it until the OOM reaper fired. submit() now refuses past listeners.max_queued_connections (default 1024) and the listener closes the refused connection, which sheds load in the one way a client can see and retry. The IPC dispatch pools keep the unbounded default: their producer is the local supervisor, not a remote peer, and dropping a message there would silently lose federation work rather than shed a connection.
  • Federation typing and receipt EDUs can no longer grow memory without bound. typing_users only shrank when a previously-seen user sent typing=false, and receipts was upserted but never reaped, so a peer could grow either indefinitely by varying room_id and user_id. The real bound is membership: an EDU for a room this server holds no membership row for is now dropped — ephemeral state for a room we are not in has no use, and without the check a peer could mint entries for room ids it simply invented. Hard caps back that up for a peer that is a legitimate member of a very large room, evicting the oldest entry. A receipt transaction legitimately batches several rooms, so an unknown room is skipped rather than failing the whole EDU.
  • The media repository has capacity limits. security.media.max_records, security.media.max_total_size and security.media.max_size_per_user bound the in-memory index, which previously had no cap on records, bytes, or per-user usage. All three default to no limit. An upload past a limit is refused with 507; nothing is evicted, because clients hold mxc:// URIs for what is stored and eviction would break those links rather than shed load. Bytes are counted over stored blobs, so a deduplicated upload consumes a record but no bytes.

High-severity correctness (findings 8, 14, 15)

  • An SSO redirectUrl allowlist entry naming a bare origin no longer matches other origins. redirect_url_is_allowed() was a bare starts_with() with no boundary, so an entry of https://client.example.com -- the natural way to write "this whole origin" -- also matched https://client.example.com.evil.test/callback. An attacker who registered that domain received a freshly minted m.login.token. The character after the matched prefix must now be a URL delimiter (/, ?, #) or end-of-string, so a bare-origin entry still works as an origin allowlist and a path-scoped entry still works as a path allowlist.
  • Federation media downloads derive Content-Disposition from the inline-safe allow-list. build_federation_media_download_body() hardcoded inline for every content type, so a peer's clients were told to render whatever we served -- an executable, a scripted HTML document -- inline in the media origin's context, bypassing the allow-list the local download path applies. The allow-list now lives in media::content_type_is_inline_safe() and both paths use it; unknown types fail closed to attachment.
  • Every manual runtime.mutex unlock/lock pair around an outbound call is now RAII. Fourteen endpoints released the global runtime mutex by hand before a blocking outbound call and re-took it afterwards. A throw between the two left the mutex unlocked, which serialises every client request and inbound federation transaction behind a lock nobody holds. Each is now a ScopedGuardRelease scope -- an immediately-invoked lambda where the call's result is read after the lock is re-taken, a plain block where it is not.

Fail-closed hardening (findings 21-24)

  • A failed prctl(PR_SET_DUMPABLE, 0) now fails the core-dump policy. The result was discarded and success reported on setrlimit(RLIMIT_CORE, 0) alone. RLIMIT_CORE=0 stops the kernel writing a core file for an ordinary crash, but it does not clear the dumpable flag, and a core_pattern that pipes to a handler (systemd-coredump, apport) is not bound by the process rlimit — so the process image, master key included, could still be captured while the policy reported success.
  • Secret files must be owner-read-only. is_secure_secret_file() never checked owner_write, so a 0600 secret passed despite docs/hardening.md documenting owner-read-only since the check was written. Operators must chmod 0400 their secret files before upgrading; the server refuses to start otherwise. Packaging now provisions the registration token as 0400 owned by the service account, replacing 0640 root:merovingian — that mode was already rejected by the group-read rule, so a packaged install could not start with a generated token.
  • server.server_name is validated against the Matrix grammar. config::validate() checked only that it was non-empty, so values like :8000 reached runtime and were used to build user IDs and federation routing keys. It now runs through auth::server_name_is_valid — the same check applied to every remote server name — so a name this server accepts for itself is one its peers will also accept.
  • A malformed expires_at is an expired token, not a permanent one. parse_expires_at() returned nullopt for an unparseable value, which every caller reads as "never expires", so a corrupt or attacker-modified row became a credential that could not be aged out. It now parses as the epoch, which every expiry comparison reads as long past. The cost of being wrong is one re-login.

BSD platform hardening (findings 9, 10)

  • The startup hardening self-check no longer blocks BSD startup. Privilege drop, filesystem restrictions and core-dump policy sat behind an __linux__ guard even though geteuid() and getrlimit() are POSIX, so a FreeBSD, OpenBSD or NetBSD server reported unknown for controls it had actually applied — and is_ready(), which treats anything but enabled as a blocker, then refused to start it. All three are documented Tier 1 platforms. Those probes now run everywhere; no_new_privs and capability bounding, which have no BSD equivalent to probe, use the "not applicable on this platform" idiom the pledge/unveil and Capsicum checks already use on Linux.
  • BSD servers now clamp RLIMIT_CORE. setrlimit(RLIMIT_CORE, 0) is POSIX but was only applied on the Linux path, so a BSD server could dump core with its master key and every signing secret in the image.
  • The thumbnail worker is sandboxed on FreeBSD and OpenBSD. The sandbox step was seccomp-only under #if defined(__linux__), so on the BSDs the worker decoded untrusted PNG and JPEG bytes with nothing but rlimits between an image-decoder bug and the rest of the system. It now enters pledge("stdio") on OpenBSD and Capsicum capability mode on FreeBSD — by the time harden() runs, stdin and stdout are already open and the worker needs nothing else for the rest of its life. NetBSD has no equivalent in-process primitive and keeps rlimits alone; that gap is recorded in docs/hardening.md.

PostgreSQL privilege separation covers migrations (finding 18)

  • DDL now runs as the migration role, not the login role. Both roles were provisioned and the request path already dropped to the DML-only runtime role, but schema migrations ran as the login role — which carries every privilege both sibling roles have — so the separation did not cover the one operation that actually needs DDL. open_postgresql_persistent_store() now takes database.migration_role, assumes it before applying pending migrations, and resets the session before assuming the runtime role.
  • Fail-closed. A migration role that cannot be assumed refuses the open rather than falling back to the login role, which would silently restore the privilege level the separation removes. A pending migration is the only thing that reaches the role switch, so a misconfigured migration_role breaks an upgrade rather than every routine restart, and leaving it unset keeps single-role deployments working exactly as before.
  • Upgrading needs one operator step. ALTER TABLE is permitted only to an object's owner, and objects created before 0.12.5 are owned by the login role — which is precisely why migrations ran as the login role until now. Re-run packaging/postgresql/provision-roles.sql before upgrading: it ends with a scoped transfer of the public tables and sequences the login role owns. The startup diagnostic names that step if it was missed, and the PostgreSQL CI job runs the same statements so the sequence is exercised. A fresh install needs nothing.

Not REASSIGN OWNED: that operates on everything the role owns, including objects the system pins, so it fails outright when the login role is the cluster bootstrap superuser — which is what the official postgres container produces and what an operator reusing the postgres role has.

Regression cover for the audit fixes

Every finding above now has a test that fails without its fix. The ones that resisted a direct assertion were made testable rather than left uncovered:

  • The core-dump policy decision is a pure predicate. platform::core_dump_policy_is_satisfied() takes the observed RLIMIT_CORE pair and dumpable flag, so finding 21 is asserted directly. The live probe cannot be driven from a test: lowering RLIMIT_CORE's hard limit is irreversible for a non-root process, so a test that set it would poison every test after it in the same binary.
  • The signing-secret key cache is observed through an unreadable file. Making the master key file unreadable while leaving its identity unchanged means a second call that still returns a key can only have come from the cache (finding 3) — no seam required. The scenario skips loudly when run as root, where the permission bits would not bite and it would otherwise pass without proving anything.
  • Rotation is exercised through a legacy plaintext row. An encrypted key cannot be loaded without the master key at all, so rotation refuses one step earlier and never reaches the encrypt path finding 1 is about. The scenario rewrites the stored row into the pre-0.12.5 format first, which is exactly the state of a server being upgraded, and asserts on the refusal reason so it cannot pass on an unrelated failure.
  • Manual lock release is now a scripts/reject-unsafe.sh gate. Finding 14's fix is structural, so the guard against regression is too: a new guard.unlock() in include/ or src/ is rejected unless annotated // LOCK_RELEASE: reviewed — <reason>. The handful of legitimate releases that remain — releasing before notify_one, or before a function that self-locks — are annotated rather than refactored: each guard is a function-local std::unique_lock, so an early exit releases it rather than stranding it, and converting them would buy no safety.
  • The thumbnail worker round-trip is named as the sandbox guard. Finding 9's restrictions apply to a child process and cannot be asserted from the parent; a correctly sandboxed child is indistinguishable from an unsandboxed one except by what it can still accomplish. The existing integration scenario is that test, on whichever Tier 1 platform's CI job runs it.
  • docs/security-coding-rules.md gains the three rules these findings established: secret files are 0400, non-crypto modules erase secrets through core::secure_zero(), and request guards are released with ScopedGuardRelease.

Findings 5 and 20 remain covered only indirectly — both are memory-residue properties, asserted through their observable half (every surviving secret holder is a locked buffer; the unbuffered read returns exact bytes across chunk boundaries) rather than by inspecting freed heap.

Connection-test teardown (sanitizer finding)

  • test_http_server_listener_flow.cpp let the runtime die while pool workers were still using it. All twelve scenarios declared the thread pool before the runtime, so reverse-declaration destruction ran ~runtime first while a worker could still be inside serve_connection holding a ConnectionContext that references it. Firing the shutdown signal and joining the accept-loop thread only stops new work being submitted; the per-connection closures already handed to the pool are still in flight, and ~ThreadPool is what joins those. ASan reported stack-use-after-scope naming exactly that read.

Fixed in two independent ways rather than one. Each scenario now calls pool.request_stop() immediately after joining the accept-loop thread, at a point where the client sockets are already closed so a parked worker sees EOF and exits promptly — and where a hang, if one ever happened, would name that line instead of timing out the whole binary. The pool is also declared after the runtime, so unwind order is correct even if a future scenario forgets the explicit stop.

Pre-existing and unrelated to the audit: http_server.cpp is untouched on this branch. It is timing-dependent, which is why the first attempt at this fix relied on declaration order alone and could not be shown to be safe — see the commit history for the OpenBSD timeout that followed it and the evidence that neither proposed mechanism for that timeout survived scrutiny.

Cover the fail-closed branches the audit fixes introduced

Coverage measurement over the branch's own added lines found three clusters with no test driving them. Two are now covered; the third cannot be, and is recorded here rather than left to look like an oversight.

  • The stored-secret refusal paths. Four branches decide whether the server signs with a key it has actually authenticated, and none was exercised: an encrypted secret with no master key configured, a secret sealed under a different master key (a Poly1305 tag mismatch, meaning a rewritten or tampered row), a secret that decrypts cleanly but is not an Ed25519 secret key's length, and a legacy row holding undecodable base64. Each now has a scenario asserting refusal. Signing with wrong-length material does not fail loudly — it produces signatures no peer can verify — so the length check in particular is worth pinning.
  • The new capacity config keys. listeners.max_queued_connections, security.media.max_total_size, security.media.max_size_per_user and security.media.max_records are an operator's only control over how much memory a connection flood or upload spam can consume. Now covered for applied values, explicit 0 (the documented way to disable a cap), malformed and negative input, and the full unsigned 64-bit range — an overflow to zero would read as "no limit", the opposite of what was asked for.
  • core::secure_zero is pinned directly rather than only through its caller.

Still uncovered, deliberately: the identity-server call sites in client_server.cpp (they need a live or mocked IS, and the RAII rewrite only moved pre-existing untested code), the PostgreSQL migration-role path (it needs a live database, so the Linux coverage job cannot reach it), and two branches in crypto/master_key.cpp that require sodium_mlock or a key derivation to fail — neither of which a test can provoke without a seam that would itself weaken the code under test.

0.12.4

Security audit of the 0.12.3 tree. Two of the findings below are privilege escalations reachable by an ordinary room moderator or a federating peer.

  • Authorization rule 9 was implemented only for content.users. Sub-rules 9.1-9.3 (type validation), 9.5 (the scalar keys users_default, events_default, state_default, ban, redact, kick, invite) and 9.6/9.7 (the events and notifications maps) were absent, so the only bound on a m.room.power_levels event was that its sender met the old state_default. A moderator at power 50 could set users_default: 100, drop their own users entry — permitted by 9.8's own-entry carve-out — and resolve to power 100 along with every other member, taking the room. Setting ban: 0 or kick: 0 handed those powers to everyone at less effort. All of rule 9 is now enforced, bounded in both directions as the spec specifies.
  • content.events no longer shadows the scalar power-level keys. extract_power_level_key fell back to looking a missing top-level key up inside content.events, which maps event types to levels. Omitting top-level ban and sending events: {"ban": 0} therefore set the effective ban level to zero — a second, independent route to the same escalation, and one that needed no rule-9 gap at all.
  • m.room.create authorization rule 1 was entirely unimplemented. None of 1.1 (no prev_events), 1.2 (the room_id/sender domain relationship, or in v12 that no room_id is present), 1.3 (a recognised content.room_version) or 1.4 (content.creator before v11; content.additional_creators in v12) was checked. Under 1.2 in particular, any federating server could mint a room whose ID claimed another homeserver's domain. The gap was held in place by a conformance scenario asserting that room versions 1-5 have no domain check — a premise the repository's own vendored rooms/v1.md contradicts, and whose fixture put the room ID and sender in the same domain so it never exercised the case either way. That scenario now asserts what it builds, and the mismatching case has its own.
  • Authorization rule 8 is enforced. A state event whose state_key starts with @ must have that key match the sender. Without it any member holding state_default could write state keyed to another user's MXID, which clients routinely read as authored by that user.
  • Local event authorization no longer fails open. In room_service.cpp the authorization call sat inside three nested guards with no else branch, so a parse failure, an unrecognised room version, or a room whose m.room.create was absent from local state all fell through to composing and returning the event unauthorized. The denial for a missing create event already existed as rule 2 inside authorize_event_against_auth_events; it was simply never reached. Every path is now fail-closed. The federation ingest path and state resolution always called the check unconditionally and were unaffected.
  • Canonical JSON duplicate-key detection is O(n) instead of O(n²). Both the parser's per-key std::ranges::any_of scan and the serializer's nested object_has_duplicate_keys loop were quadratic in an object's member count, bounded only by the 1 MiB body cap — roughly 1.1×10¹⁰ string comparisons for a body of ~150,000 unique keys, on unauthenticated endpoints such as /login and /register. Both now use a hash set, and the parser enforces an explicit max_object_members cap of 65536 as defence in depth.
  • The master key is no longer read from disk twice per authenticated request. lookup_token_hashes derives both the v3 and v4 access-token HMAC keys, and each derivation opened and read the operator's master key file — so every authenticated request performed two blocking reads of the server's root secret and two sodium_mlock/munlock cycles on a 4 KiB buffer. Under concurrency that mlock churn could exhaust RLIMIT_MEMLOCK, at which point SecretBuffer silently falls back to swappable memory for the key every other key is derived from. Both keys are now derived once and cached, invalidated on the file's (path, size, mtime) identity so replacing the key still takes effect without a restart — the steady-state cost is one stat instead of two full reads.
  • A failed mlock of the master key is now reported. SecretBuffer already recorded it and exposed is_locked(); nothing ever called it, so a server holding its root secret in swappable memory said nothing at any log level. It now warns once per process, naming the remedy. Deliberately not fatal: refusing to start would turn a hardening shortfall into an outage.
  • Media responses carry the spec's content-security headers. Client-facing download and thumbnail responses set only Content-Type and CORS headers — Content-Security-Policy appeared nowhere in the tree, and Content-Disposition only on the federation multipart body. All three of CSP, Cross-Origin-Resource-Policy: cross-origin and Content-Disposition are now set, with inline used only for the content types the spec's "Serving inline content" allowlist permits and attachment for everything else. No filename parameter is emitted yet — this server does not persist the upload filename, which the spec permits and which is now tracked as a gap.
  • /login is throttled per account, not only per source IP. The rate limiter's per-user tier keys on the authenticated user, which pre-login is nobody, so guesses against one account spread across many source IPs accumulated against nothing. Failures are now counted against the claimed user ID — whether or not it exists, so the throttle cannot be used to probe which accounts are real — with a lockout after five failures in fifteen minutes, cleared by any successful login.
  • POST /_matrix/client/v3/account/deactivate is implemented. It previously had no handler at all; the path appeared only as a literal in the suspended-user allowlist, so a user whose credentials were compromised could not close their own account — only an admin could apply a reversible lock/suspend. The endpoint now performs m.login.password UIA, marks the account permanently deactivated (schema version 14 adds a deactivated column to users), revokes every access and refresh token including the caller's own, and replaces the password hash with an unmatchable value. The users row is retained so the localpart is never reissued. Every identity-server-bound 3PID the account holds is unbound as part of deactivation, reusing the stored id_server/client_secret/sid 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, no-support when any could not be. Unlike the standalone unbind endpoint, a failure there does not fail the deactivation — it is irreversible and leaves no owner to retry with.
  • membership_acceptor and invite_handler take the runtime lock. Both mutated store.rooms/state/events and the stream-ordering counters with no lock held, where their sibling ingest_pdu_event locks correctly. Not a live race in any shippable configuration — the worker-relay path already held the global mutex and is always constructed when federation is enabled — but a trap for the first change that made the worker optional. They now take the global recursive mutex only: acquiring a room stripe there would invert the documented stripe-then-global order against ingest_pdu_event.
  • The dead X-Matrix/TLS peer cross-check was removed. SignedFederationRequest::tls_peer_server_name was assigned nowhere in production, only in four conformance tests, so its !empty() guard could never fire while its comment and those passing tests asserted a defence-in-depth control that did not exist at runtime. Inbound federation TLS is one-way — the originating server presents no client certificate and SNI carries our own hostname — so there is no legitimate peer name to compare. X-Matrix Ed25519 verification remains the actual, fail-closed gate and is untouched.
  • Sliding-sync connection state is now bounded. HomeserverRuntime::sliding_sync_connections is keyed on a client-chosen conn_id and was only ever erased on the M_UNKNOWN_POS path, so a client varying conn_id grew it until the process was OOM-killed. sliding_sync.hpp documented a one-hour idle eviction and last_used was written on every request — but never read, and no sweep existed. Idle connections are now evicted after that hour, and each user/device is capped at eight with least-recently-used eviction.
  • Review round (PR #488). Codex review found nineteen issues on the branch above; the ones that changed behaviour are recorded here rather than folded silently into the entries they correct.
  • The key-resolution negative cache was keyed on the origin alone, so naming a real server with a bogus key ID poisoned that server's own traffic for the full TTL — a denial of service against a third party, worse than the amplification the budget was added to bound. Now scoped to (origin, key ID).
  • The budget keyed on the direct TCP peer, which behind the reverse-proxy deployment the example config describes is 127.0.0.1 for every remote server. Trusted-proxy resolution is now a shared helper used by both the client-server limiter and the federation budget.
  • A resolution the key cache can serve does no network work and is no longer charged, so a peer signing with a second published key is not throttled.
  • Rule 9 validation was written against room version 11 and applied to all versions. Versions 1-9 permit a string-encoded integer power level, so integer-only validation rejected valid historical events; notifications joined the rules only in v6; and rule 1.4 tests for the presence of creator, not its type. All three are now version-aware, and string-encoded levels are read as their value rather than silently defaulted.
  • Deactivation discarded both token-revocation results and still returned 200. A failed refresh-token update was directly exploitable, because refresh_local_session checked only that the user existed. Revocation failure now fails the request, and a deactivated user cannot refresh at all — two independent gates rather than one.
  • Deactivation ignored the request's id_server, so a client selecting a trusted identity server got a result implying its choice was honoured.
  • A throttled login returned 429 with M_FORBIDDEN and no delay; it is now M_LIMIT_EXCEEDED carrying retry_after_ms.
  • The sliding-sync connection key joined user, device and connection ID with /, which a device ID may contain, so device A/B's state was counted and evicted under device A. Components are now length-prefixed. Pruning also evicted a connection to make room that an existing-key poll did not need.
  • The master-key cache identity was (path, size, mtime), which cp -p and reproducible deployment tooling preserve; it now uses inode, device and ctime, which an in-place overwrite cannot leave unchanged.
  • object_has_duplicate_keys kept its noexcept after gaining an allocation, turning bad_alloc into std::terminate.
  • KeyResolutionSlot held its runtime by raw pointer.
  • scripts/reject-unsafe.sh no longer flags comments as libsodium violations. Its comment-exclusion filter, grep -vE '^[[:space:]]*//', ran after grep --line-number had already prefixed every hit with path:line:, so the anchor could never match and the filter excluded nothing. Any comment that merely named a libsodium symbol failed the pre-commit gate. A gate that reports violations where there are none is one people learn to bypass, which is the more serious failure. The anchor now accounts for the prefix; the rule itself is unchanged and still rejects real calls.
  • Pre-authentication remote-key resolution is now budgeted. resolve_inbound_remote calls the remote-key resolver before check_inbound_request_signature — necessarily, since verifying a signature requires the key being resolved — so under the shipped security.federation.default_policy=allow an unauthenticated sender could name any origin in an X-Matrix header and make this server perform .well-known + SRV + DNS discovery plus an outbound GET /_matrix/key/v2/server against a host of their choosing. That is load here and a reflection vector at whoever was named, and CachedServerDiscovery gave no protection because it is keyed on the very field the attacker varies. Bounded now by key_resolution_per_ip_rate (10/60s), key_resolution_max_in_flight (8, rejecting rather than queuing over the cap) and key_resolution_failure_ttl (300s negative cache) — keyed on source IP, because the origin is attacker-chosen. Both pre-authentication containers are capped with FIFO eviction; an unbounded pre-auth map would have been the same class of bug this fixes. A throttled resolution logs key_resolution.throttled at warning with the origin and the budget that denied it, because the failure this risks introducing — a new federation partner intermittently failing to join — is otherwise very hard to diagnose. default_policy=deny with a populated allowed_servers list already closed this independently.
  • config/merovingian.conf.example was missing 21 of the 109 configuration keys the parser accepts, plus two whole prefix families. All 109 keys were documented in the user manual, so this was an example-file gap rather than an undocumented-feature one — but an operator working from the example had no sign that SSO, OIDC discovery, Application Services, the PostgreSQL role separation added in 0.12.3, the per-origin federation budgets, or the rate-limit tiers existed at all. Added with the reasoning for each, and the file re-verified against the parser: --dry-run reports "Configuration validation passed" with zero findings, and an unknown key is still rejected, so the check is meaningful rather than vacuous.
  • migrations/AGENTS.md carried two contradictory "Current highest" lines (012 and 013) — the 013 branch added a line instead of updating the existing one. Now a single line, reading 014.

0.12.3

  • PostgreSQL privilege separation is applied rather than only provisioned. packaging/postgresql/provision-roles.sql created a DDL migration role and a DML-only runtime role, and set_postgresql_role/reset_postgresql_role existed and were covered by an integration scenario — but nothing called them. A deployment that had provisioned the roles still served every request with its login role's full privileges, and testing the functions in isolation could never have caught that. open_postgresql_persistent_store now assumes the migration role for the DDL phase, drops to the runtime role to serve (whether or not a migration ran, so a steady-state restart is equally constrained), and refuses to open when a configured role cannot be assumed — continuing would serve with wider privileges than asked for, silently. Configured via database.migration_role/database.runtime_role; both empty issues no SET ROLE, leaving existing single-role deployments unaffected.
  • PostgreSQL role changes are now correctly classified as restart-required. build_reload_plan reported database.migration_role/database.runtime_role changes, but derived their policy from reload_policy_for_key, which did not list either key and so fell through to the reloadable default. An operator editing the separation and reloading would be told the change had applied while every open connection kept its old role, since roles are assumed with SET ROLE at connect time — the exact silent mismatch the surrounding code comment claimed to prevent. Found by investigating low patch coverage on the change: the line was untested and wrong.
  • database.migration_role/database.runtime_role documented in the user manual's configuration reference and reload-policy table, where they had been absent since being introduced.
  • Email pushers are refused instead of stored and discarded. kind: "email" was accepted, persisted, listed back by GET /pushers, then skipped at delivery time, so the user had positive confirmation of notifications that would never arrive. Now 400 M_INVALID_PARAM naming the reason.
  • The "no tested upgrade path" blocker was withdrawn as an error. Both backends already migrate on open via apply_pending_migrations, each step in its own transaction, and the path is exercised end to end. It had been added to the charter after reading initialize_current_schema's empty-database guard and concluding no migration existed, without reading the following lines where it does.

0.12.2

  • docs/todos/production-milestone.md corrected after the 0.12.1 release. The SSO login row still read "m.login.sso not advertised; GET /_matrix/client/v3/login/sso/redirect unrouted" after that work had shipped — the row was never updated when the branch merged, so the document that decides whether the project can be released understated its own readiness. Rows crediting feature/release-blockers now cite 0.12.1, since the branch is merged and gone, and the closing summary lists the rows that are genuinely still open rather than excluding only HTTP keep-alive.

  • leave_room releases runtime.mutex through a scoped guard. The remote leave exchange released the lock by hand and re-acquired it at nine separate return paths; a throw anywhere across the three federation round trips left the mutex down for that thread and the next request it served. The exchange now sits in one ScopedGuardRelease scope, deleting all nine manual re-locks, with the send_leave result declared outside it because the membership write that consumes it must hold the lock.

  • join_room/leave_room were recorded in 0.12.1 as sharing create_room's lock gap; they do not. Both already released the guard around their federation calls. The regression written to expose the supposed stall passes against the unfixed code and is kept as a guard on the property.

  • An appservice's sender_localpart user is now checked against other registrations' exclusive namespaces. That user is created at startup, so an exclusive-namespace collision over it is guaranteed rather than latent — yet nothing detected it. load_registrations()/validate_registrations() now take the homeserver's server_name (defaulted empty, which skips only this check) so the sender user id can be built and matched. The check runs both directions per pair and never self-compares: a registration's own sender legitimately sits inside its own exclusive namespace, and reporting that would refuse to start every correctly configured bridge.

  • docs/todos/production-milestone.md reassessed and restructured. The charter conflated "we must know this" with "CI must prove it every pull request", and carried one item — "complete full Matrix v1.19 conformance and endpoint coverage" — that could never be marked done and so blocked 1.0.0 permanently while communicating nothing. Soak, load and chaos runs cannot produce meaningful numbers on shared, time-limited runners: thresholds loose enough to survive runner noise prove nothing, tight enough to mean something flake, and a green soak badge that means nothing invites the conclusion that concurrency has been checked. They are now maintainer-run release evidence recorded at tag time beside the compiler flags and checksums, which is stronger evidence than a CI figure because the hardware is real. Five blockers remain: /messages chunk-relevant state, PostgreSQL privilege separation that is provisioned but never applied, an external Complement run, push silently discarding email pushers, and — newly identified — no tested upgrade path from a database written by an older release. Descoped items are recorded under "Decided against" rather than deleted, so a later audit does not silently reintroduce them.

  • GET /messages honours lazy_load_members. The endpoint returned the room's full current state, including every member, defeating the flag exactly where it costs most — pagination repeats that payload for every page. Two causes: lazy_load_members was never parsed anywhere in the codebase, and /messages passed a default-constructed filter to its state builder, so no state filtering happened at all. RoomEventFilter now carries lazy_load_members and include_redundant_members, the request's own filter reaches the builder, and membership events are restricted to senders appearing in the chunk. Non-member state is untouched — it is relevant to rendering the chunk regardless.

The two endpoints' state fields are NOT the same contract: /context returns "the state of the room at the last event returned" and reconstructs it temporally, whereas /messages returns "state events relevant to showing the chunk". Copying /context's temporal walk would have been wrong.

0.12.1

Release-blocker closures branch (feature/release-blockers). The audit that defines this branch is recorded in docs/todos/production-milestone.md "Release-blocking functional holes" — eight functional holes plus the still-open production gates. This section is updated as each closure lands.

  • Documented the release-blocking audit: the functional-hole table in docs/todos/production-milestone.md, and a new "Runtime concurrency" ledger row in docs/todos/capability-gaps.md recording that HomeserverRuntime::mutex serialises every client-server request and inbound federation transaction, and that every HTTP response carries Connection: close.
  • HTTP/1.1 persistent connections (keep-alive, RFC 9112 §9.3). Connections are now served as a sequential loop of request rounds instead of one-request-per-connection: HTTP/1.1 clients get persistent connections by default, Connection: close is honoured and echoed, and HTTP/1.0 clients are kept alive only on an explicit Connection: keep-alive. Each request's body is drained exactly (Content-Length bytes) before the connection waits for the next request, and surplus bytes are carried into the next round, so request boundaries are never lost; pipelining itself stays out of scope — pipelined requests are buffered and answered strictly in order. Kept-alive responses carry Connection: keep-alive plus an advisory Keep-Alive: timeout=N hint. Security composition: the connection guard is now phase-aware (http::connection_should_close) — an idle parked connection is bounded only by the operator-tunable idle window (server.http.keep_alive_idle_seconds, default 15 s, 1..300, restart required) and is never killed as "slow", while the full slowloris policy applies unchanged once request bytes arrive; a process-wide CAS cap (server.http.keep_alive_max_connections, default 8, 1..4096) bounds how many connections may be parked waiting for a next request, so a client cannot park a main-pool worker thread per connection beyond the operator's budget. /sync long-polls still run on the dedicated sync pool, and a kept-alive long-poll connection is handed back to the main pool for its next round, preserving pool separation. New unit tests (tests/unit/test_http_keep_alive.cpp) cover the framing decision, header token parsing, policy validation, and the idle-vs-slow guard phases; new integration scenarios (tests/integration/test_http_server_listener_flow.cpp) exercise two sequential requests over one real connection (plain and TLS), body-drain framing across a pipelined send, Connection: close honouring, idle-window expiry, and the parked-connection cap.
  • Production-grade rate limiting (tiered policies). Every client-server route is now classified into one of six explicit tiers in http::rate_limit_tier_for() (src/http/rate_limit.cpp) — one greppable prefix table: auth_sensitive (/login, /register, /refresh and the */requestToken family, 20/min), media (20/min), sync (90/min), federation routes on the client listener (120/min), admin (30/min) and generic (90/min via the operator-tunable client_rate_limits.default_per_ip). Per-IP resolution is most-specific-first: an operator per-prefix override (client_rate_limits.per_ip.*) beats an operator tier override (client_rate_limits.tier.<name>, validated against the tier table so a typo is a parse finding), which beats a built-in per-endpoint refinement (keys/devices 30/min, search 20/min), which beats the tier default. Unauthenticated routes are bucketed per remote IP — the credential/enumeration surface now gets the tighter auth_sensitive cap instead of the generic fallback. A misconfigured entry at any level resolves to "invalid_policy" and the request is denied, so a bad operator value can never silently mean "no limit" (fail-closed, issue #412). Inbound federation gains a per-origin cap on requests OUTSIDE /send (query, backfill, membership, key and state endpoints) — keyed by the X-Matrix verified origin, tunable via security.federation.per_origin_request_rate (default 600/min); /send keeps its weighted transaction/PDU/EDU trio so a transaction and its contents are never double-counted. Tier keys are restart_required in the config-reload plan, matching the rest of the rate-limit block. New unit scenarios in tests/unit/test_http_rate_limit_tiers.cpp cover the classification table, tier-name round-trip, tier defaults, override precedence and the fail-closed path; new conformance scenario asserts the 429 error shape; new integration scenario serves a 429 mid-pipeline on a kept-alive connection and recovery after the window.

Adds SSO login (m.login.sso) per Matrix v1.19 CS API §"Client login via SSO": homeserver-side routing, redirect validation, and the m.login.token exchange. Merovingian does not itself speak an external SSO protocol (CAS/SAML/OIDC) — this closes the homeserver-side half of the flow so an operator's own external IdP adapter has a real integration point to call into.

  • m.login.sso is now advertised by GET /_matrix/client/v3/login, including the configured identity_providers list, but only when server.sso.* is fully configured (homeserver::sso_is_configured) — otherwise the flow is silently omitted rather than half-advertised.
  • GET /_matrix/client/v3/login/sso/redirect[/{idpId}] is now routed. Validates the client-supplied redirectUrl against an operator-configured HTTPS allowlist (server.sso.redirect_url_allowlist) before ever building a redirect — this is the control that prevents the endpoint from being an open redirect (Matrix v1.19's own security-considerations section for this endpoint documents the exact attack it closes). Validates idpId against identity_providers, returning 404 M_NOT_FOUND for an unrecognised IdP per spec. On success, 302-redirects to the operator-configured server.sso.authorization_url.
  • m.login.token is now implemented in POST /_matrix/client/v3/login (previously entirely absent — only m.login.password was handled). homeserver::complete_sso_login is the integration point an operator's external SSO adapter calls once it has authenticated the user and mapped them to a local Matrix user id: it re-validates redirectUrl, mints a short-lived (~30s), single-use login token using the same keyed-hash machinery access tokens use, and returns the client's redirectUrl with any stale loginToken parameters stripped and the new one appended, per spec. homeserver::redeem_login_token consumes the token exactly once (atomic redeem, backend-first) and hands off to homeserver::login_local_user_by_id — the same account lock/suspend gate and session-issuance path password login uses, factored out of login_local_user so SSO login cannot bypass it.
  • New durable login_tokens table (schema version 12, migration 012_login_tokens.sql), structurally disjoint from access_tokens — the same token-confusion mitigation already applied to openid_tokens — so a login token can never authenticate an ordinary client-server request.
  • New config surface: server.sso.enabled, server.sso. authorization_url, server.sso.redirect_url_allowlist, and server.sso.identity_providers.<idpId>.{name,icon,brand}. Parse-time validation fails closed: enabled=true with a missing authorization_url or an empty redirect_url_allowlist is rejected outright, as is a duplicate/empty identity-provider id, an empty name, or a non-mxc:// icon. Begins closing the Application Service API gap (Matrix v1.19): the entire surface was previously unimplemented (see docs/todos/capability-gaps.md). This entry covers the registration/auth layer and outbound transaction delivery; the outbound query hooks and /thirdparty/* remain and land in a follow-up entry under this same section.

  • Appservice registration files. New merovingian::appservice module (include/merovingian/appservice/, src/appservice/) parses registration documents (as_token, hs_token, id, url, sender_localpart, namespaces.{users,aliases,rooms} with per-entry exclusive/regex) and builds an in-memory AppserviceRegistry at startup from the new appservice.registration_files config key (config::AppserviceConfig, restart-required). Registration files are parsed as JSON — a strict subset of the YAML the spec describes — rather than pulling in a new YAML dependency; see the doc comment on AppserviceRegistration for the rationale. as_token/hs_token are held in core::SecretBuffer and compared with crypto::constant_time_equal_variable_length, never ==. Duplicate id/as_token across registrations is rejected at load time (spec MUST) and drops the whole set rather than guessing a winner.

  • as_token bearer auth + ?user_id=/?device_id= masquerade. A client-server request bearing a registered appservice's as_token now authenticates as that appservice's sender_localpart user by default, or as the user named by ?user_id= when it falls within the appservice's users namespace (403 otherwise); ?device_id= is honoured when it names a device already known to belong to that user (400 M_UNKNOWN_DEVICE otherwise). Resolved once per request in handle_client_server_request_impl via an internal, never-on-the-wire token substitution (appservice/masquerade_token.hpp) so every existing authenticated_user/authenticated_session/authenticated_admin_user call site, and every deeper handler that re-derives identity from a bare access_token, works unmodified for both ordinary sessions and appservice masquerades. A raw incoming token already in the internal reserved shape is rejected before any auth logic runs, closing the obvious forgery path.
  • m.login.application_service. POST /register and POST /login accept type: m.login.application_service (bearer-authenticated with as_token), bypassing the ordinary registration flow entirely per spec ("Server admin style permissions") — no registration token, no UIA. New passwordless account creation (register_appservice_user) and passwordless login (login_appservice_user) in auth_service.cpp, sharing the existing session/token-issuance tail with the password path via a new complete_login helper. GET /login now advertises m.login.application_service alongside m.login.password.
  • Namespace exclusivity. An appservice's exclusive namespace now blocks entity creation by anyone else: ordinary POST /register and m.login.application_service registration both reject a username inside another appservice's exclusive users namespace with M_EXCLUSIVE, and PUT /_matrix/client/v3/directory/room/{roomAlias} and POST /createRoom (room_alias_name) both reject an alias inside an exclusive aliases namespace the same way — except for the owning appservice itself.
  • Outbound PUT /_matrix/app/v1/transactions/{txnId} delivery. New AppserviceClient (src/appservice/appservice_client.cpp) PUTs one event per transaction, authenticated with Authorization: Bearer <hs_token>. Wired synchronously into room_service.cpp's dispatch_appservice_delivery from send_event(), local membership transitions, and the federation-accepted-PDU path, for every registered appservice whose namespaces match the event (registration_interested_in_room_event). The delivery cursor (next_txn_id, delivered_stream_ordering, and the currently in-flight pending_txn_id/pending_stream_ordering) is durable in the new appservice_txn_cursor table (migration 013_appservice_txn_cursor.sql, schema version 13), so a retried transaction reuses the exact same txn_id and event after a restart, per spec ("Homeservers MUST NOT alter ... events they were going to send within that transaction ID on retries"). Appservice URLs are operator-configured (registration files), not attacker-influenced, so http::OutboundRequest gained an opt-in allow_cleartext_http escape hatch from its https-only invariant (default false, unchanged for every other caller) — the spec's own canonical registration example, and most real-world bridges, use plain http://127.0.0.1:....
  • Known gaps, tracked in docs/todos/capability-gaps.md: the outbound transaction delivery above sends one event per transaction synchronously on the request path (no background dispatch, no retry/backoff timer independent of new events, no ephemeral data); the outbound GET /_matrix/app/v1/users/{userId} / /rooms/{roomAlias} query hooks are scaffolded (client methods exist, unit-tested) but not invoked from any local-miss call site; the /_matrix/app/v1/ping ↔ POST /_matrix/client/v1/appservice/{id}/ping round trip and /_matrix/client/v3/thirdparty/* remain unimplemented.

  • Application service registration files are now parsed as YAML. The module parsed JSON only, reasoning that JSON is a subset of YAML 1.2. That is true and was not sufficient: the spec's own registration example and every shipped bridge use block-style registration.yaml, so the homeserver could not load a single real registration file. registration_yaml.cpp is a strict bounded subset — 2-space block maps and lists, plain and quoted scalars, # comments, null/~ — where anchors, aliases, tags, block scalars, tabs, document markers and unknown keys are parse failures rather than silent coercion, with fail-closed input bounds (256 KiB file, 4 KiB line, 64 namespace entries, 32 protocols). load_registration_file() tries YAML first and falls back to JSON, so anything that loaded before still loads. Covered by a test that parses the spec's example verbatim, trailing comment and empty rooms: [] included.

  • An empty namespace list written as rooms: [] is accepted. The YAML parser required every namespace key to carry an empty value with an indented list beneath it, which rejected the spec's canonical example. Empty flow sequences are now accepted — they carry no entries, so supporting them costs nothing — while every other inline value, including non-empty flow sequences the bounded subset cannot parse, is still rejected rather than silently dropping entries.
  • Namespace regexes are anchored to whole identifiers (security). namespace_matches() used std::regex_search, which is unanchored. A namespace regex is a claim over an identifier, not a substring: the spec's own example pattern @_irc_.* therefore also matched @evil@_irc_bob:example.org, so one appservice's exclusive namespace could claim unrelated local users and block their registration. Now std::regex_match, with coverage for the genuine match, the substring escape, and a shared-prefix non-match.
  • src/appservice/AGENTS.md added, recording the YAML-subset rules, the anchored-matching requirement, the token-handling rules, and one known gap: overlapping exclusive namespaces between different appservices are not detected, so two registrations may both exclusively claim the same pattern.
  • Third-party lookups implemented, closing the last functional gap in the Application Service API. All six client-server routes — GET /_matrix/ client/v3/thirdparty/{protocols, protocol/{protocol}, location, location/{protocol}, user, user/{protocol}} — are now backed by the corresponding outbound GET /_matrix/app/v1/thirdparty/* calls (AppserviceClient::query_thirdparty_*, new in appservice_client.cpp). A protocol name is owned by whichever registration lists it in protocols:; an unrecognised protocol is 404 M_NOT_FOUND with no outbound call. The alias/userid lookups (location/user with no protocol) have no such ownership signal, so they fan out to every registered appservice and aggregate the results. An appservice that is unreachable, times out, or answers with a non-200/malformed body contributes nothing rather than failing the whole client request — only when no appservice contributed anything does the route 404 (or, for /protocols, return {}, matching the previous placeholder's behaviour for the no-appservice case so Element does not resume its retry loop). instance_id is minted by the homeserver per spec, never trusted from the appservice reply. Every field taken from an appservice's JSON reply is type-checked and bounded (dropped malformed array entries, non-string fields members dropped, capped array/object sizes and string lengths) before being echoed to a client — a bridge is not a trusted peer. Outbound calls carry the hs_token as a Bearer credential exactly like the existing transaction/query calls. All six routes bypass client-side rate limiting and require authentication, matching the spec's per-endpoint Rate-limited: No / Requires authentication: Yes flags exactly — the previous /protocols placeholder was incorrectly rate-limited. New unit tests (tests/unit/test_appservice_client.cpp, tag [thirdparty]) cover the disabled (url: null) fast path and the bounded/defensive response parsing in isolation; new conformance tests (tests/conformance/test_appservice_thirdparty_conformance.cpp) cover the documented status codes, the auth/rate-limit flags, and the no-appservice-registered cases; new integration tests (tests/integration/test_appservice_thirdparty_flow.cpp) drive the full path against a real local mock appservice, including multi-appservice aggregation and the unreachable-appservice degrade-not-fail guarantee.

  • resolve_policy_server_hook no longer holds HomeserverRuntime::mutex across the trust_safety.policy_server_url round trip. handle_federation_http_request was already fixed for this (#415), but register_local_user, create_room (and room upgrade), and the media download/thumbnail policy check all called the same function directly while still holding the lock — an operator running with trust_safety.enabled inherited a policy server that, if slow or unreachable, froze registration, room creation, and media reads for every other user for up to policy_server_timeout. The fix wraps the remainder of resolve_policy_server_hook (the injectable test hook and the real OutboundClient::perform call alike) in one NetworkIoUnlock scope, so every call site is fixed at the source. New regression coverage in tests/integration/test_request_lock_contention_flow.cpp (registration, room creation, media download, each gated on a blocking policy-server hook while an unrelated request is asserted to complete promptly).

  • create_room was silently double-locking HomeserverRuntime::mutex, which made NetworkIoUnlock a no-op for anything blocking inside it — found by the new regression coverage above, not by inspection. HomeserverRuntime::mutex is a std::recursive_mutex, and create_room takes its own lock on it (it must stay independently callable outside a request handler — see local_smoke_flow.cpp). Called from client_server.cpp, which already held its own outer guard for the whole request, this recursively re-locked the same mutex a second time — legal for a recursive_mutex, and therefore silent. NetworkIoUnlock's single unlock() call only undoes one level of that recursion: it released the outer guard (the one RequestLockScope had published) while create_room's own, inner guard — the one actually in scope at the point of the network call — stayed locked. The mutex was therefore never really available to another thread during the (now-unlocked-in-theory) policy-server round trip. This means the NetworkIoUnlock mechanism introduced in 0.11.13 was incomplete for any call chain that passed through a self-locking function: it correctly modelled "the caller holds one lock, release it," but not "the caller holds one lock, a callee recursively takes a second one, release the one actually in scope." The room-creation regression test added alongside the resolve_policy_server_hook fix above caught this immediately — it deadlocked rather than passing, exactly as a lock bug should. Fixed by publishing RequestLockScope around create_room's own guard (room_service.cpp) and releasing the caller's outer guard before delegating to it at all three call sites (client_server.cpp ×2, local_http_router.cpp ×1) — the unlock/call/relock idiom already used for join_room — so create_room's own guard becomes the only lock in scope and is the one NetworkIoUnlock actually finds and releases. join_room/leave_room share the identical self-locking shape and very likely have the same latent gap for their own outbound federation calls; fixing them is tracked as separate follow-up work, deliberately out of scope here to avoid last-minute scope creep on a lock-correctness change.
  • New load/soak evidence harness for the global runtime lock (tests/integration/test_runtime_lock_soak_flow.cpp, opt-in behind the build_load_tests Meson option — a manual tool with no CI wiring, matching the existing build_live_tests precedent). Drives concurrent /sync long-polls, ordinary reads, message sends, and signed X-Matrix-authenticated inbound federation transactions — all over real sockets with HTTP/1.1 keep-alive — and reports throughput and p50/p95/p99 latency per category, so the "Global runtime lock" release blocker in docs/todos/production-milestone.md can be closed (or kept open) on measurement rather than intuition. **Measured, 20 s runs, 6 simulated users
  • 2 simulated federation peers, before (pre-fix source reverted to a scratch copy) vs. after this change**: reads ~385→374 req/s (p50 7.2→7.4 ms, p95 25.0→26.4 ms), sends ~478→469 req/s (p50 6.7→7.0 ms, p95 21.4→21.8 ms), federation transactions ~208→204 req/s (p50 6.4→6.7 ms, p95 22.6→23.0 ms, capped by the federation per-origin transaction rate limiter, not lock contention) — statistically indistinguishable, as expected: this change's two fixes (below) do not touch that hot path when trust_safety is disabled. The direct evidence for those two fixes is instead the regression tests themselves: before, the affected request deadlocks (unbounded — confirmed by direct reproduction under a bounded external timeout); after, --durations yes shows registration/room-creation/media scenarios completing in 66–132 microseconds. See docs/http-transport.md, "Load/soak evidence", for how to run it and the full measured results.

  • Appservice user and room-alias query hooks are wired. query_user() and query_room_alias() were implemented but called from nowhere, so a bridge that materialises users and rooms on demand got a flat 404 for exactly the identifiers its namespace exists to claim. GET /directory/room/{roomAlias} and GET /profile/{userId} now consult the appservice whose namespace covers the identifier and re-check locally afterwards. Only the owning appservice is queried — fanning out would leak which aliases and users clients look up — and an unreachable or declining appservice is a miss, not an error, so a bridge outage cannot turn a 404 into a 502.

  • Exclusive namespaces claimed by two appservices are now boot-time findings. Nothing enforced the spec's exclusivity guarantee across registrations: two could both exclusively claim @_bridge_.* and whichever the registry consulted first silently won. Detection compares pattern strings and is deliberately conservative — differing regexes with overlapping matches are not reported, because a false conflict that refuses to start a correct deployment would be worse than the gap.
  • The request lock is restored on every path out of a released region. The dispatcher's guard.unlock(); f(); guard.lock(); triples never re-acquired if f() threw, leaving the request — and the next one on that thread — running with the locking invariant broken. Adds ScopedGuardRelease and converts 8 of 21 sites. Three publicRooms/alias federation-proxy paths were worse: they released the guard and returned through dispatch_err/dispatch_resp without ever re-acquiring, reading rt.cors — which config hot-reload can replace — unsynchronised.
  • Federation worker hardening no longer fails open on non-Linux platforms. apply_worker_hardening() (src/platform/runtime_hardening.cpp) unconditionally returned accept() on every non-Linux build regardless of federation.worker.apply_hardening (default true), so the worker logged "runtime hardening applied (seccomp filter active)" and handled untrusted federation traffic completely unsandboxed on FreeBSD/OpenBSD/NetBSD, with zero test coverage anywhere in the suite. It now returns a fail-closed rejection (worker_hardening_unavailable_decision(), exposed for testing) naming the unavailable controls and the apply_hardening=false escape hatch, so the worker process refuses to start rather than run unsandboxed; the WorkerSupervisor sees a crash-looping child and federation degrades to 503 instead of ever accepting unsandboxed traffic. The main process's own hardening_self_check.cpp/is_ready() gate was already correctly fail-closed (any unknown or disabled control blocks startup) — this was specifically a gap in the worker's separate hardening path. MEROVINGIAN_TEST_DISABLE_HARDENING is untouched by this change and continues to let the in-process test suite skip hardening in the shared Catch2 process. New unit coverage in tests/unit/test_runtime_hardening.cpp ([worker]) proves the fail-closed decision on every platform without installing a real sandbox on the test process, plus a non-Linux-only scenario exercising apply_worker_hardening() itself.
  • Real listener coverage in CI. Audited the only test that spawns the real merovingian-server binary (tests/integration/test_server_startup_hardening_flow.cpp) and found it proved only "reaches the listening state and shuts down cleanly", never that the server actually serves a request — and that every existing CI job WARN-skipped before reaching even that, because the runtime hardening self-check can only report ready=true on a hardened-profile build (debug builds never advertise _FORTIFY_SOURCE) run by a process holding CAP_SETPCAP (root containers fail the privilege-drop check instead), a combination no existing job satisfied. Added a real HTTP GET over a real TCP socket against the spawned process's client listener (asserting a genuine 200 from /_matrix/client/versions) plus a post-shutdown connect-refused check to that scenario, and a new ubuntu-hardened-listener-coverage CI job (.github/workflows/ci.yml) that builds the hardened profile and grants the built server binary CAP_SETPCAP via sudo setcap cap_setpcap+ep so the scenario actually reaches the live-serving assertions instead of skipping.
  • Build-time link-time hardening enforcement. Added scripts/check-elf-hardening.sh, which statically inspects a built binary's ELF headers (PIE, PT_GNU_RELRO, DT_BIND_NOW, non-executable PT_GNU_STACK) and fails closed if any is missing — a CI-time counterpart to the runtime ELF probe that needs no live process, so it runs identically whether the job is root, non-root, or containerized. Wired into the new ubuntu-hardened-listener-coverage CI job against every shipped binary (server, federation worker, thumbnail worker, db-migrate).
  • PostgreSQL role-provisioning script for deployments. Audited the "enforce runtime/migration PostgreSQL role grants" gate: the CI-level test coverage (postgres-integration.yml, test_postgresql_persistence_flow.cpp) proving transaction rollback, migration ordering, and runtime-role DDL denial against a real temporary PostgreSQL database was already closed; packaging/ provisioned none of it for a real deployment. Added packaging/postgresql/provision-roles.sql, the same migration/runtime-role grant pattern CI already proves, for operators to run. Documented the remaining gap precisely in docs/database-persistence.md "Next starting points": nothing in the live startup path calls set_postgresql_role() yet — merovingian-db-migrate never opens a database connection (it only prints an offline plan) and bootstrap_local_database() always applies migrations automatically through the same connection/role that then serves runtime traffic.
  • Release evidence recorded in release notes. Added scripts/collect-release-evidence.sh, run per-platform in .github/workflows/release.yml, which 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's Ok/Fail/Timeout counts, and lists the mandatory fuzz target names. publish-alpha-release now folds every platform's evidence plus a package checksums section into the published release notes, alongside the existing GPG signatures.
  • Audit findings recorded, not just fixes: mandatory fuzz execution (.github/workflows/fuzz.yml already ran set -eu + -error_exitcode=77 on every push/PR, no continue-on-error) was already closed and the capability-gaps.md row describing it as missing was stale; Complement was audited and found to mean the upstream matrix-org/complement Go suite, not this repo's own tests/fixtures/complement/ JSON-fixture flow test — a root Dockerfile exists but is not Complement-compatible and no Go/CI wiring exists anywhere, so this remains open and unstarted. Config-profile CI-naming (scripts/validate-phase1-config.sh and its "Phase 1" step name) also remains open — not touched this pass.

0.11.13

Fixes a production stall where one slow federation peer froze the whole server, and a sync response that grew without bound.

  • runtime.mutex is no longer held across blocking outbound federation calls. Every client-server request and every inbound federation transaction contends on that single mutex, and two request paths were making synchronous network calls while holding it: POST /_matrix/client/v3/keys/query (one federation /user/keys/query per remote server, each budgeted remote_timeout_seconds, default 60) and remote media download/thumbnail fetches. A peer that accepted the connection and then went quiet therefore froze the entire process for the duration of the timeout — local reads such as GET /profile and GET /media/config took 20 seconds, and inbound /send transactions from unrelated servers queued for 44 seconds behind a single thumbnail fetch. Server discovery, the outbound perform, and the media redirect resolution now release the mutex for the network round trip and re-acquire it before touching runtime state again. Signing still happens under the lock, so the Ed25519 secret span is never read unsynchronised. Added RequestLockScope / NetworkIoUnlock (homeserver/request_lock.hpp) to make the release RAII rather than hand-written unlock()/lock() pairs.
  • A user is reported at most once in device_lists. changed and left were appended to once per change row, so a subject with N recorded changes appeared N times — and because an initial sync reports every change from stream position zero, that set grew for the lifetime of the account. One observed sliding sync response was 769 KB of repeated user IDs while carrying zero rooms and zero to-device messages. A new shared collector (sync/device_list_delta.hpp) collapses the change log to one entry per subject, resolves a subject that both changed and left to its most recent change type, and sorts the result so identical store state always produces identical response bytes. Applied to /sync, the MSC4186 e2ee extension, and GET /_matrix/client/v3/keys/changes. Initial syncs still report the full deduplicated set, which is what prompts a freshly logged-in device to /keys/query its own user's devices.

0.11.12

Fixes a class of bug where the server's Ed25519 signing key and the thing that actually signs with it drifted apart, which on a long-running server broke every locally composed event.

  • A signing key is no longer silently replaced when its published window lapses. valid_until_ts tells federation peers when to re-fetch the key list; it was being treated as a lease on the server's own identity. Once a server's uptime exceeded the seven-day window, ensure_runtime_server_signing_key skipped the (now "expired") key and generated a brand-new one. Two things then went wrong at once: the runtime signing provider had been built at startup from the old key and was never rebuilt, so every locally composed event failed with signing key not held: <new key id> — clients saw 403 M_FORBIDDEN "event authorization or signing failed" on every PUT /rooms/{roomId}/send/..., including mobile clients that could otherwise sync normally — and the new key had never been published, so peers rejected requests signed with it (401 on POST /_matrix/federation/v1/user/keys/query). The key is now selected regardless of its window, and a window that is more than halfway elapsed is rolled forward and persisted instead. A key is generated only on first boot or through an explicit rotate_server_signing_key.
  • ensure_runtime_server_signing_key now guarantees the runtime signing provider holds the key it returns, rebuilding the provider when it does not. The preferred key is also always treated as active by collect_active_server_signing_keys, so a window that could not be refreshed (database write failure) degrades to peers re-fetching sooner rather than to a server that cannot sign at all.
  • GET /_matrix/key/v2/server no longer advertises a validity the server does not honour. The published valid_until_ts is now exactly the window persisted for the key, rather than an unconditional "now + 7 days" that the store never recorded.
  • The cached key-server document is refreshed. It was published once at startup and served from the lock-free cache forever, so after seven days of uptime every peer that re-fetched it saw a valid_until_ts already in the past. The cache entry now carries a one-hour refresh deadline; past it the fast path re-publishes before serving.
  • reset_runtime_crypto_provider honours its documented no-op under an external signing override. It previously replaced the federation worker's IPC-backed provider with one built from local secrets the worker does not have.
  • Inbound federation requests whose X-Matrix parameters are unquoted are no longer rejected. parse_x_matrix_authorization_header required every value to be a quoted string, but the spec (Server-Server API, Request Authentication) says values "must be enclosed in quotes if they contain characters that are not allowed in tokens ... if a value is a valid token, it may or may not be enclosed in quotes". A peer sending a bare token had its whole request refused — for PUT /_matrix/federation/v1/send/{txnId} that silently discarded its PDUs behind a 502 malformed federation authorization. Unquoted RFC 9110 §5.6.2 tokens are now accepted; a value carrying anything outside the tchar set is still rejected, because the sender was required to quote it.
  • A rejected federation Authorization header is now diagnosable. The 502 above was logged as nothing but a status code, so a peer whose traffic was being dropped looked identical to one that never called. A federation_proxy.authorization_unparsed warning now records the target, the header's length, and whether it carried the X-Matrix scheme — never the header itself, which is the peer's reusable credential.
  • GET /_matrix/client/v1/rooms/{roomId}/threads is implemented (spec v1.4+, previously 404 — Element X calls it on every room open). Returns the room's thread roots ordered by each thread's latest_event, most recently active first, each carrying its bundled m.thread aggregation (latest_event, count, current_user_participated). include=all|participated is honoured and any other value is a 400 M_INVALID_PARAM rather than a silent fallback to all; limit defaults to 50, caps at 500, and rejects zero; from / next_batch paginate on the latest-event stream ordering, with next_batch omitted on the last page. Child events from ignored senders are excluded from the aggregation (spec MUST) and a root sent by an ignored user is returned redacted rather than omitted. Known gap recorded in docs/todos/capability-gaps.md: bundled aggregations are still produced only by this endpoint, not by /sync, /messages, /context, /relations, or /event/{eventId}.
  • reset_runtime_crypto_provider no longer moves each key_id twice, which left runtime.database.signing_secret_keys — documented as "keyed by key_id" — holding empty ids, so every lookup by key id silently missed. Found while verifying the provider-consistency check above, which the empty ids would have reduced to an unconditional rebuild on every request.
  • The federation dispatch worker's signing identity follows key rotation. It snapshotted the key when constructed and kept signing with the retired key afterwards, which peers reject because the rotation publishes that key under old_verify_keys with a past expired_ts. rotate_server_signing_key now hands the worker its new identity directly — not wire_federation_callbacks, which returns early once the callbacks exist and so would never have run the refresh.

0.11.11

Started as a documentation-only audit of the routed client-server surface against Matrix v1.19, recorded in docs/todos/capability-gaps.md; the branch then closed one of the gaps the audit found.

  • Routed POST /_matrix/client/v3/search (server-side event search), the last previously-unrouted endpoint the 0.11.11 audit found in the Client-Server API. Searches content.body (m.room.message), content.name (m.room.name), and content.topic (m.room.topic) — a case-insensitive substring match, not stemmed/tokenised full text search — across rooms the caller is currently joined to, never searching end-to-end encrypted rooms (spec MUST). filter (RoomEventFilter), keys, order_by (rank/recent), event_context (before_limit/after_limit/ include_profile), include_state, and groupings (room_id/sender) are all honoured rather than silently ignored. m.ignored_user_list is applied via the shared trust_safety::ignore_list helper, resolved once per request. Unlike /messages, this endpoint has no per-room scope, so a new ClientApiLimits::max_search_events_scanned (2000) bounds how many candidate events a single request will JSON-parse and text-match before it must hand back a next_batch continuation instead of scanning further; /_matrix/client/v3/search also gets its own 20/min-per-IP rate-limit tier (the same tier as media) rather than the generic 90/min fallback. Known gaps, recorded in docs/todos/capability-gaps.md: only rooms the caller is currently joined to are searched (the spec's fuller "including rooms you have left" scope is not implemented — the same join-only membership gate /messages//context//event/{eventId} already use); order_by: rank is only locally accurate within a request's bounded scan window, not a global top-K across the caller's entire matching history; per-group next_batch pagination is not implemented (spec allows this); and the MSC3765 extensible-topic content['m.topic'] representation is not indexed.
  • Routed GET /_matrix/client/v3/rooms/{roomId}/context/{eventId} (event context for permalink resolution). Requires authentication and the same joined-membership gate as /messages and /event/{eventId}; an unknown event id and an event id belonging to a different room both fail closed with an identical 404 M_NOT_FOUND so a member cannot tell the two cases apart. limit (spec default 10, applied to the combined size of events_before and events_after) is clamped to the same maximum /messages uses. filter accepts a JSON RoomEventFilter, reusing the existing sync::EventTypeFilter/RoomFilter machinery via a new sync::parse_room_event_filter_argument() rather than a second filter implementation; a non-empty value that fails to parse as a JSON object is rejected with 400 M_BAD_JSON instead of being silently ignored. start and end reuse the plain stream-ordering token format GET /messages already produces, so a client can continue paginating from a context response with GET /messages?from=<token>&dir=<b|f>.

  • Two whole spec sections were absent while going untracked, so they were invisible in our own planning: the Application Service API (no as_token/hs_token, no /_matrix/app/v1/* outbound calls, no m.login.application_service, no namespace exclusivity — bridges and bots cannot be run against this homeserver) and the Push Gateway API. Both now have their own sections with per-surface rows.

  • Push was the most misleading entry. Push-rule CRUD is genuinely spec-covered, but GET /_matrix/client/v3/pushers returns a hardcoded {"pushers":[]}, POST /pushers/set validates the body and discards it, and nothing ever posts to a gateway's /_matrix/push/v1/notify — the sole reference to that path is an outbound-URL validator. No user can receive a push notification. The capability row now carries a split status and the endpoint rows are marked scaffolded.
  • Newly recorded as unrouted: POST /search (full-text event search; only the unrelated user_directory/search exists), GET /notifications, GET /rooms/{roomId}/context/{eventId} (permalink resolution, user-visible in Element — since routed, see above), POST /user/{userId}/openid/request_token, SSO login (m.login.sso is not advertised), and m.ignored_user_list enforcement — the account-data key is storable because that store is generic, but ignored users are never filtered from /sync, so the ignore silently does nothing.
  • The document gained a "Reading this document" note recording the two failure modes this audit exposed: silence in the ledger does not imply coverage, and a routed endpoint returning 200 does not imply an implemented one.
  • Step 1 of closing the push gap: a new push module (pusher persistence, push-rule evaluation, and a Push Gateway API client), built but not yet wired into the client-server router — routing is a follow-on. Delivery is disabled by default (server.push.enabled = false) so merging this cannot cause an existing deployment to start sending gateway traffic on upgrade.
  • New 008_pushers migration (schema version 8) adds a pushers table keyed on (user_id, app_id, pushkey) per the spec's uniqueness rule, with store_pusher/find_pusher/delete_pusher/list_pushers_for_user on PersistentStore, following the PersistentThreePidBinding pattern.
  • merovingian::push (include/merovingian/push/push_rules.hpp, src/push/push_rules.cpp): a pure, side-effect-free push-rule evaluator. parse_push_ruleset() parses a stored ruleset once into a typed PushRuleset; evaluate_push_rules() evaluates one event per call against it, honouring override > content > room > sender > underride precedence, .m.rule.master's absolute priority, the "never notify for your own events" rule, and all six spec condition kinds: event_match, contains_display_name, room_member_count, sender_notification_permission, event_property_is, and event_property_contains. The latter two were initially stubbed to PushConditionKind::unknown (never-match) — closed in the same branch, since .m.rule.is_user_mention/.m.rule.is_room_mention are built on exactly these two kinds and would otherwise never fire, silently disabling @-mentions. Both share the evaluator's existing dot-separated-path resolver (resolve_event_property, refactored out of event_match's string-only lookup) so the \./\\ escaping rules (docs/matrix-v1.19-spec/appendices.md#dot-separated-property-paths) are honoured identically across all three key-based condition kinds; value comparison is exact-type (a string "true" never equals boolean true, per spec) and fails closed on unresolvable paths or non-array targets.
  • merovingian::push::PushGatewayClient (include/merovingian/push/push_gateway_client.hpp, src/push/push_gateway_client.cpp): an outbound POST /_matrix/push/v1/notify client built on the SSRF-safe http::OutboundClient + CachedServerDiscovery pattern used by IdentityServerClient — a pusher's gateway URL is client-supplied, attacker-influenced data, so it is resolved the same fail-closed way federation and identity-server traffic is, never via ad-hoc DNS. notify() fails closed with no network call at all when server.push.enabled is false, and surfaces the spec's rejected pushkey list to the caller without touching the database itself.
  • New config::PushConfig (server.push.*: enabled, connect/total timeouts), modelled on OidcConfig/IdentityServerConfig; restart-required on change, same lifecycle as the identity-server client.

  • Step 2: wired the push module into the homeserver — push notifications are now actually delivered. GET /pushers and POST /pushers/set no longer lie to the client, and a sent event can now reach a real Push Gateway.

  • GET /pushers returns the caller's persisted pushers in the spec's Pusher shape instead of a hardcoded empty array.
  • POST /pushers/set persists via store_pusher; kind: null now deletes via delete_pusher instead of being a silent no-op. Implemented the append field per spec: append: false (the default) removes any other user's pusher sharing the same app_id+pushkey before storing this one; append: true leaves it in place. The existing body validation, including the https + /_matrix/push/v1/notify pusher-URL check, is unchanged.
  • Delivery: room_service.cpp's send_event() — the single choke point for both PUT .../send/{eventType}/{txnId} and PUT .../state/{eventType}/{stateKey} (both rewrite to it) — now, after persisting an event, evaluates that event against every local joined recipient's push rules (default_push_ruleset(), the exact ruleset GET /pushrules already serves — moved to a new shared homeserver/default_push_ruleset.{hpp,cpp} so the two can never drift apart) and builds one Push Gateway notification per (recipient, "http" pusher) pair whose evaluation says "notify". counts.unread reuses sync::count_notifications/sync::read_receipt_ordering (the same unread baseline /sync already computes) summed across every room the recipient is joined to, rather than a second implementation. "email" pushers are accepted and persisted but not yet deliverable — no email transport exists — and are simply skipped at delivery time.
  • Gated and off the request path. All of this is skipped entirely — no rule evaluation, no pusher lookup, no thread — when server.push.enabled is false (the default), so merging this cannot cause an existing deployment to start sending gateway traffic on upgrade. When enabled, the actual PushGatewayClient::notify() calls run on a detached std::async task parked in HomeserverRuntime::orphan_futures_ — the same mechanism join_room's background member-fill task already uses — so a slow, hostile, or unreachable gateway can never block or fail the request that triggered it; runtime.mutex is only briefly re-acquired to delete a pusher whose pushkey the gateway reports in its rejected array.
  • New push::TestForcedPushGatewayResolution test-only seam on PushGatewayClient (mirrors identity::TestForcedIdentityResolution exactly) so integration tests can drive a real local HTTPS mock gateway without weakening the production SSRF/discovery boundary; wired onto HomeserverRuntime::test_forced_push_gateway_resolution (always empty in production).
  • New conformance coverage (tests/conformance/test_push_notifications_conformance.cpp): the set/get round-trip, kind: null delete, append cross-user removal semantics, and the https/notify-path URL rejection. New integration coverage (tests/integration/test_push_delivery_flow.cpp, against a real local TLS mock gateway): delivery is skipped while push.enabled is false; an enabled gateway receives a correctly-shaped notify request for a message that matches a push rule; a rejected pushkey is deleted; and message sending still succeeds — promptly, without blocking — when the recipient's gateway is unreachable.
  • Known gap, recorded honestly: GET /notifications remains unrouted, "email" pushers are accepted and persisted but never delivered (no email transport exists), and there is no gateway retry/backoff (spec SHOULD, not MUST — a failed delivery attempt today is not retried). See docs/todos/capability-gaps.md.
  • Two follow-on fixes to the push delivery path landed in the same branch, closing the gaps the paragraphs above recorded:
  • Bounded the background delivery tasks (resource-exhaustion fix). dispatch_push_deliveries parked one future per qualifying event in HomeserverRuntime::orphan_futures_ and never reaped a completed one — unlike join_room's make_join race, which already reaps before parking. With push.enabled = true this was an unbounded memory leak (one entry per event, forever) and unbounded OS thread creation under sustained message volume or a hostile client. Fixed by two changes shared with the join-race path via one helper, reap_completed_futures() (runtime.hpp/.cpp): completed futures are removed from orphan_futures_ before a new one is parked (checked with a non-blocking wait_for(0s), never .get()/.wait() on a still-running one), and a new counter, HomeserverRuntime::push_delivery_in_flight_ (guarded by the existing orphan_futures_mutex_, tracked separately from the join race so the two cannot starve each other), is checked against a fixed cap, k_max_in_flight_push_deliveries (128), before a task is spawned. At capacity the delivery is dropped and logged at WARN rather than spawned or blocked on: a missed push is recoverable (the client still sees the event on its next /sync), an exhausted thread pool is not. New tests/unit/test_runtime_orphan_futures.cpp exercises reap_completed_futures() and the pure cap-boundary check (at_background_task_capacity()) deterministically via std::promise, with no real threads or timing dependence; new integration coverage in tests/integration/test_push_delivery_flow.cpp proves the real call site reaps before parking and drops (rather than spawns) once the real counter is saturated.
  • Membership transitions now notify. Delivery previously fired only from send_event(). persist_membership_transition — the shared helper behind invite/ban/kick/leave/knock — plus join_room and invite_user_by_threepid now route through the same pipeline via a new dispatch_membership_push_notification(). The one correctness subtlety: build_pending_push_deliveries() only evaluated rules against LocalRoom::members, i.e. joined members, but an invitee is by definition not (yet) joined, so .m.rule.invite_for_me — a default, enabled rule whose entire purpose is to notify an invite recipient — could never have fired even once the pipeline was reachable. build_pending_push_deliveries() now accepts an extra_recipients span for exactly this case; an entry equal to the sender or already a joined member is silently absorbed, so every call site passes the membership target unconditionally rather than special-casing which transitions have a real, distinct recipient. New integration coverage proves an invited user's pusher receives the notification, and that a failing gateway never fails or blocks the invite itself.
  • See docs/threat-model.md ("Push delivery background tasks were unbounded" and "Membership transitions never reached push delivery") and docs/architecture.md's "Fire-and-forget background work" section for the full design writeup.
  • Follow-up fix: the in-flight counter introduced above deadlocked runtime shutdown whenever a push delivery was still running. dispatch_push_ deliveries's background task decremented push_delivery_in_flight_ as its final action while holding orphan_futures_mutex_ — the same mutex HomeserverRuntime::~HomeserverRuntime() (and the integration test helper wait_for_background_tasks()) held for their entire drain, including the blocking future.wait() calls. A waiter that already held the mutex could never see the task finish, because the task could never acquire the mutex it needed to finish — a classic deadlock. Push delivery is disabled by default, so no running deployment was affected, but any deployment that enables it would hang on server shutdown with a delivery in flight; the integration suite hit this directly (a 583s timeout in "a completed push-delivery task is reaped before the next one is parked").
  • HomeserverRuntime::push_delivery_in_flight_ is now std::atomic<std:: size_t> (runtime.hpp). The dispatcher-side check-and-increment stays under orphan_futures_mutex_ (already held there to reap orphan_futures_), keeping that read-modify-write correct against the cap; the background task's decrement no longer takes the mutex at all, so its completion can never depend on it. The move constructor and move assignment operator (runtime.cpp) switch from std::exchange (not valid on a non-movable std::atomic) to .exchange(0U).
  • HomeserverRuntime::~HomeserverRuntime() and wait_for_background_tasks() (tests/integration/ test_push_delivery_flow.cpp) now hold orphan_futures_mutex_ only long enough to move the parked futures out of orphan_futures_, then wait on the moved-out copies with the mutex released — never holding a lock across a blocking wait. This also stops a runtime shutdown (or a test's background-task wait) from needlessly blocking a concurrent dispatch_push_deliveries call trying to reap or park a future while the drain is in progress. Audited every other site that parks or reaps orphan_futures_ (the make_join race in room_service.cpp's join_room, twice) and confirmed none of them wait on a future while holding the mutex — this pattern was unique to the two fixed call sites.
  • New tests/unit/test_runtime_orphan_futures.cpp scenario, "HomeserverRuntime's destructor releases orphan_futures_mutex_ before blocking on a still-running background task": parks a background task gated on an external promise (so it stays in flight, unable to finish, for the whole test), destroys the runtime on another thread, and polls orphan_futures_mutex_ with try_lock() for up to 2 seconds to prove it becomes available again while the task is still blocked — deterministic, since the task's gate is never opened until after the poll, so a destructor that still held the mutex across its wait would show the mutex held for the entire window, not just possibly missed by a race.
  • Reviewed "exceeding the in-flight push-delivery cap drops the notification instead of spawning it" (test_push_delivery_flow.cpp): it already asserts the cap policy by setting push_delivery_in_flight_ directly to a value at the cap rather than physically spawning 128 real concurrent deliveries against a mock gateway, so it stays fast; no change needed there. The pure at_background_task_capacity() cap-boundary check remains covered separately in tests/unit/test_runtime_orphan_futures.cpp.
  • Closed the m.ignored_user_list gap the audit above found: the account-data key was storable but the server never acted on it. Implemented server-side enforcement per Matrix v1.19 CS API §Ignoring Users.
  • New merovingian::trust_safety::ignore_list module (include/merovingian/trust_safety/ignore_list.hpp, src/trust_safety/ignore_list.cpp): parse_ignored_user_list() parses an m.ignored_user_list account-data body into a std::unordered_set, failing safe to an empty set on malformed/absent content; resolve_ignored_users() reads a user's global ignore-list row from the store; is_delivery_suppressed() is the pure decision function — non-state events from an ignored sender are withheld, state events are exempt (spec: "Servers must still send state events sent by ignored users to clients"), and a room invite is suppressed regardless of the state-event exemption (spec: "Servers must not send room invites from ignored users to clients"). One shared predicate, called from every delivery surface rather than reimplemented per call site.
  • Wired into GET /sync (client_server.cpp): the timeline-matching loop, the invite section (looked up via the inviter's PersistentInvite:: sender_user_id), and build_room_ephemeral_events_array()'s typing and receipt entries. The ignore set is resolved once per request and reused, not re-read per event.
  • Wired into MSC4186 sliding sync: build_room_response() (sliding_sync_room_builder.cpp) filters the per-room timeline and, since MSC4186 has no separate invite-state surface like legacy /sync's rooms.invite.<room_id>.invite_state, applies the same invite override inside required_state for the one case a client can still observe an invite: an explicit room_subscriptions entry naming a room the caller was invited to. build_extensions() (sliding_sync_extensions.cpp) applies the same filter to the receipts and typing extensions. Both take the caller's resolved ignore set as a parameter, resolved once by sliding_sync_json() rather than per room/extension.
  • Wired into GET /messages (messages_json) and GET /context/{eventId} (room_context_json): both now take the requesting user's mxid and drop non-state events from an ignored sender. /context deliberately never filters the requested event field itself — the caller asked for context around that exact event_id (e.g. a permalink); only events_before/events_after are filtered.
  • Wired into push delivery: build_pending_push_deliveries() (room_service.cpp) checks each recipient's ignore list before even looking up their pushers, so an ignored sender's message — or a membership/invite push routed through the same function via dispatch_membership_push_notification — never reaches dispatch_push_deliveries and never queues a background delivery task.
  • Judgement calls: ephemeral typing/receipt entries are treated as ordinary (non-state) "events sent by that user" and suppressed the same as messages, since the spec's client-behaviour clause is written in those general terms and neither is a state event. Sliding sync's room list only ever enumerates joined rooms (pre-existing, unrelated to this change) — invite suppression there is provable only via room_ subscriptions, which is what the new test exercises.
  • New tests/conformance/test_ignoring_users_conformance.cpp: an ignored sender's message is absent from /sync while an unignored sender's is present; a state event from an ignored sender is still delivered; a room invite from an ignored sender is withheld from /sync while another user's invite is not; un-ignoring restores delivery of events sent afterward; a malformed m.ignored_user_list is treated as empty; /messages and /context apply the same filter (with /context's target-event exemption).
  • New tests/unit/test_trust_safety_ignore_list.cpp: pure-function coverage of parse_ignored_user_list, resolve_ignored_users, is_delivery_suppressed (including the state-event and invite-override cases and fail-safe behaviour with an empty ignore set), and event_json_is_state_event's state_key-presence discriminator.
  • New scenarios in tests/integration/test_sliding_sync_flow.cpp (timeline suppression; invite suppression via room_subscriptions) and tests/integration/test_push_delivery_flow.cpp (an ignored sender's message, and an ignored inviter's invite, never queue a background push task).

  • Routed GET /_matrix/client/v3/notifications, the last unimplemented piece of this branch's push notification work. Requires authentication; paginates via from/next_token, reusing the plain-stream-ordering token format GET /messages already established (next_token is the stream_ordering of the first not-yet-returned notification, so a follow-up request with from=<next_token> continues without gap or overlap); only=highlight filters to notifications whose matched rule set the highlight tweak; limit defaults to 50 and clamps to 1000; read reflects the caller's own m.read/m.read.private receipt in that room, computed at request time via the existing sync::read_receipt_ordering() rather than stored on the row.

  • New notifications table (migrations/009_notifications.sql, schema version 9): user_id, room_id, event_id, stream_ordering, ts, actions, profile_tag, highlight, primary key (user_id, event_id). PersistentNotification plus store_notification/ list_notifications_for_user on PersistentStore, hydrated for both SQLite and PostgreSQL, following the PersistentPusher pattern.
  • Notifications are recorded in room_service.cpp's build_pending_push_deliveries() — the point where an event is already evaluated against each recipient's push rules — for every local recipient whose evaluation resolves notify: true. Recording is unconditional on server.push.enabled and on the recipient having a registered pusher: the spec says the endpoint returns events the user "has been, or would have been, notified about," so a user with push turned off, or never configured a pusher, still needs their history. Only the Push Gateway delivery half of that function (building a PendingPushDelivery and dispatching it to a real gateway) stays gated on push.enabled + a pusher. Suppression for an ignored sender is unchanged — recording happens after the same trust_safety::is_delivery_suppressed check the gateway path already used, so an ignored sender's event is invisible to /notifications too, not a separate code path that could drift.
  • PushEvaluationResult (push_rules.hpp) resolves a matched rule down to notify/tweak_sound/tweak_highlight rather than keeping its raw actions array, so a new push_notification_actions_json() helper reconstructs the conventional shape (["notify", {"set_tweak": ...}]) for storage — profile_tag is left empty, since recording happens once per (user, event) rather than once per pusher and so cannot name one pusher's configured tag.
  • Retention: store_notification prunes the oldest rows for a user_id beyond a fixed cap, k_max_notifications_per_user (200, persistent_store.cpp), after every insert — the same "bound all resources" policy already applied to orphan_futures_/ k_max_in_flight_push_deliveries for push delivery's background tasks, now applied to this table's row count.
  • New tests/conformance/test_notifications_conformance.cpp: a notification appears with every required field after a matching event; only=highlight filters to the highlight-tweaked notification (with the unfiltered response as the positive counterpart); limit bounds the page and next_token appears only when more results remain; from/ next_token pagination collects every notification exactly once with no gap or overlap; read reflects an m.read receipt (notifications at or before the receipted event become read, later ones stay unread).
  • New scenarios in tests/conformance/test_client_server_conformance.cpp replacing the two stale "404 M_UNRECOGNIZED (implementation gap)" placeholders: routed-response shape, and unauthenticated access rejected with 401 M_MISSING_TOKEN.
  • New scenarios in tests/integration/test_push_delivery_flow.cpp: a message from an ignored sender never appears in the ignoring recipient's GET /notifications (with a non-ignored sender's message as the positive counterpart), and a notification is recorded even when push.enabled is false and the recipient has no pusher registered.
  • tests/unit/test_database_persistence.cpp and tests/integration/test_persistent_homeserver_flow.cpp: updated the hardcoded migration-step/schema-version assertions for the new schema version 9 and the notifications migration step.

  • Implemented Matrix v1.19 OpenID, both halves: POST /_matrix/client/v3/user/{userId}/openid/request_token (client-server, mints a token) and GET /_matrix/federation/v1/openid/userinfo (federation, redeems it). Closes the last unrouted client-server endpoint this branch's audit had flagged (docs/todos/capability-gaps.md).

  • Security-critical design constraint: an OpenID token must never be usable as a client-server access token. Kept structurally separate rather than relying on a runtime check: a new openid_tokens table (never access_tokens), a dedicated mint path (homeserver::request_openid_ token) and a dedicated redeem path (homeserver::federation_openid_ userinfo) that are the only functions touching that table. The ordinary client-server auth gate (authenticated_user) never reads openid_ tokens; federation_openid_userinfo never reads access_tokens/ sessions. See docs/threat-model.md ("OpenID token confusion") and docs/auth-identity.md ("OpenID tokens").
  • New openid_tokens table (migrations/010_openid_tokens.sql, schema version 10): user_id, token_hash (primary key), expires_at (epoch milliseconds, always finite — unlike access tokens, an OpenID token never has "no expiry"). PersistentOpenidToken plus store_openid_token on PersistentStore, hydrated for both SQLite and PostgreSQL, following the PersistentPusher/PersistentAccessToken pattern. Retention: store_openid_token sweeps every already-expired row (across all users) on each insert, since an OpenID token's natural bound is its own expiry rather than a per-user row count.
  • POST /user/{userId}/openid/request_token: authenticated, and the path userId must equal the caller — a mismatch fails closed 403 M_FORBIDDEN via a pure string compare against the already-authenticated user, so the response cannot reveal whether some other userId exists on this server (verified against both a real other user and a nonexistent one, asserting byte-identical responses). Mints a token good for one hour (hardcoded — not operator-configurable, since a longer-lived OpenID token still cannot reach the client-server surface) and returns the spec's four required fields: access_token, token_type ("Bearer"), matrix_server_name, expires_in (seconds). Rate-limited under the same default per-IP bucket every other authenticated non-enumerated endpoint (filter, account_data, pushers) uses.
  • GET /openid/userinfo: per spec, requires no authentication and is not rate-limited — the caller may be any third-party service, not necessarily a homeserver — so it is dispatched entirely outside federation::handle_inbound_federation_request's X-Matrix signature-required path, alongside the existing GET /_matrix/key/v2/server bypass, in src/homeserver/local_http_router.cpp / src/homeserver/federation_proxy.cpp (is_federation_openid_userinfo_endpoint, federation_openid_userinfo_response). An unknown or expired token gets the identical 401 M_UNKNOWN_TOKEN / "Access token unknown or expired" body in both cases, matching the spec's own error shape, so a caller cannot distinguish "never issued" from "expired".
  • New tests/unit/test_openid_token_store.cpp: store_openid_token persists, rejects empty user_id/malformed token_hash, and sweeps already-expired rows on the next insert while leaving still-valid rows (for other users) untouched.
  • New scenarios in tests/unit/test_homeserver_auth_service.cpp: request_openid_token returns all spec-required fields; federation_openid_userinfo redeems a minted token, fails closed for an unknown token, and fails closed identically for an expired one (asserted against a real unknown-token call, not just "returns false"); an OpenID token is rejected by authenticated_user and an ordinary access token is rejected by federation_openid_userinfo — the two security-critical separation directions.
  • New scenarios in tests/conformance/test_client_server_conformance.cpp: successful mint returns all four fields; unauthenticated request rejected 401; requesting a token for another user (both a real user and a nonexistent one) rejected 403 with identical bodies; GET /openid/userinfo returns the correct sub for a token minted through the real client-server endpoint (full round trip); unknown token rejected 401 M_UNKNOWN_TOKEN; an ordinary access token rejected 401 M_UNKNOWN_TOKEN at /openid/userinfo.
  • tests/unit/test_database_persistence.cpp: updated the hardcoded migration-step/schema-version assertions for the new schema version 10 and the openid_tokens migration step.
  • Three PR #479 review findings closed in the push delivery path — two P1, one P2.
  • P1: push notifications never fired for federated-room messages. Delivery was wired into send_event() (locally composed events) and the membership paths, but an event accepted over federation persists through ingest_pdu_event() (called from the runtime.federation.pdu_sink callback wired by wire_federation_callbacks_impl in local_http_router.cpp) and only ever published the sync token — it never reached build_pending_push_deliveries/dispatch_push_deliveries. A message from a remote room member, the ordinary federated-room case, therefore produced no /notifications row and no Push Gateway request for a local recipient. Fixed with a new homeserver::deliver_federation_push_notifications() (room_service. cpp/.hpp), called from the pdu_sink lambda after a PDU is accepted — the single convergence point for both the direct main-process federation path and the worker-relayed path, so it cannot double-fire. Reuses build_pending_push_deliveries/dispatch_push_deliveries unchanged rather than a second delivery path; passes the event's state_key as an extra recipient for m.room.member PDUs the same way dispatch_membership_push_notification already does, so a federated invite can also fire .m.rule.invite_for_me. New integration coverage in tests/integration/test_push_delivery_flow.cpp: a federation-accepted message reaches a local recipient's pusher and /notifications history; a locally composed event is still delivered exactly once now that the federation path also dispatches push notifications.
  • P1: unbounded pushers per recipient. POST /pushers/set has no per-user limit on distinct (app_id, pushkey) pairs, and every notify-worthy event copied and processed a recipient's entire pusher list sequentially inside one background task — up to server.push.total_timeout_seconds (default 30s) per pusher — so a large enough list could keep a task, and repeated across events one of the 128 in-flight slots, occupied for minutes to hours; the existing 128-task cap only bounds task count, not the work inside one task. Fixed by bounding delivery, not registration: room_service.cpp's new k_max_pushers_per_delivery (10, comfortably above a real user's device count) truncates the pusher list actually contacted per event, logging push.pushers.truncated at WARN (never silently) the same way the 128-task cap logs push.delivery.dropped. Registration itself (POST /pushers/set, client_server.cpp) is unchanged — out of this branch's scope, owned by other in-flight work on this PR — so this is a delivery-side bound, not a registration-side rejection; notification history recording is unaffected (still unconditional per recipient, independent of the pusher cap). New integration coverage: a recipient with 12 registered pushers has only the first 10 actually contacted for one event.
  • P2: contains_display_name read the wrong display name. Spec: the condition "matches messages where content.body contains the owner's display name in that room" — a per-room value. build_pending_push_ deliveries instead read the recipient's account-wide profile row (database::find_profile), so a room-specific membership display name, or a stale account-wide one, evaluated the wrong text — missing a real mention or matching an unrelated one. Fixed with a new room_member_display_name() helper (room_service.cpp) that resolves the recipient's current m.room.member state event content in that room, falling back to the account-wide profile only when the membership event carries no displayname at all. This resolver remains live infrastructure feeding PushEvaluationContext::receiving_user_display_ name on every evaluation (see the P2 finding below, which removes the only default rule that consumed it); a follow-on PR #479 review pass found and closed three further defects in this same area — P1 below, plus two more P2s.
  • P1 (PR #479 review): custom pusher data members were silently dropped, both at delivery and at rest. push_gateway_client.cpp rebuilt the notify request's data object from only data_format, discarding every other member the pusher's data dictionary carried at registration — Push Gateway API v1.19: the Device object's data is "the data dictionary passed in at pusher creation minus the url key", i.e. everything else must reach the gateway verbatim (gateways commonly use custom data members for routing/credentials). The pushers table only ever stored data_url/data_format, so the loss actually started at registration time. Added data_extra_json to PersistentPusher and the pushers table (migrations/011_pushers_data_extra.sql, schema version 11) to store the rest of the dictionary, and a PushGatewayDevice::data_extra field that build_device_object() now merges into the outgoing data object (defensively skipping any stray url/format member so the routing URL can never leak into the body and format can never duplicate). Gap closed: parse_pusher_set_ body() (client_server.cpp) now captures every member of the request's data object besides url/format into MatrixPusherSetBody::data_ extra_json, the POST /pushers/set handler persists it via PersistentPusher::data_extra_json, GET /pushers echoes it back in the pusher's data object, and room_service.cpp's pusher-to- PushGatewayDevice conversion threads it into PushGatewayDevice:: data_extra so a live delivery actually carries it. New coverage: build_notify_request_body forwards custom data_extra members verbatim and excludes/deduplicates url/format; store_pusher/find_pusher round-trip data_extra_json including an upsert replacing it; test_client_server.cpp proves POST /pushers/set → GET /pushers round-trips a custom string and integer data member; test_push_ delivery_flow.cpp's "custom pusher data members survive registration, GET /pushers, and reach the push gateway's notify request" scenario proves the full path end to end against a real mock gateway.
  • P2 (PR #479 review, client_server.cpp:9071): a failed delete_pusher was reported to the client as success. The kind:null branch of POST /pushers/set discarded delete_pusher's return value with std::ignore, so a backend failure on an existing pusher still returned 200 {} — the client believes it disabled notifications while the pusher stays live and keeps receiving pushes. Now checks whether the pusher exists first (find_pusher); deleting a pusher that was never registered remains a 200 no-op, matching the endpoint's existing idempotent-retry behaviour and the spec's plain "the pusher ... is deleted" wording, but a delete_pusher failure on one that DOES exist now returns 500 M_UNKNOWN. New unit coverage (tests/unit/test_client_server.cpp): forcing the persistence backend to fail (flipping PersistentStoreBackend::sqlite with an empty sqlite_path, which persist_transaction_to_backend already fails closed on) on an existing pusher returns an error instead of 200, and the pusher remains registered afterward.
  • P2 (PR #479 review, client_server.cpp:9089): cross-user pusher replacement was not atomic. append:false (the default) deleted every OTHER user's pusher sharing the target app_id+pushkey BEFORE the replacement pusher was persisted; a store_pusher failure after those deletions left unrelated users' pushers gone with no replacement ever created, silently disabling their notifications. Reordered: the replacement is persisted first, and the other-user removals only run once that succeeds, so a store_pusher failure now returns 500 having deleted nothing. (No store-level API exists for wrapping this upsert and the other-user deletes in one transaction — store_pusher/ delete_pusher each commit and update the in-memory mirror independently — so reordering is the fix rather than a new transaction primitive; see the reasoning in client_server.cpp's comment at the call site.) New unit coverage (tests/unit/test_client_server.cpp): with the same forced-backend-failure seam, a append:false registration that fails to store leaves another user's already-registered pusher for that app_id+pushkey untouched.
  • P2 (PR #479 review): contains_display_name matched the display name as a glob pattern, not literal text. A display name is data supplied by an arbitrary user, not an author-written pattern — matching it with the same glob engine content-rule patterns use meant a display name of literally * matched every message in every room (forcing a highlight regardless of whether the message named that user at all), and ? matched any single character. Fixed with a new contains_literal_word_boundary_substring() (push_rules.cpp) that performs the same word-boundary substring search but compares the candidate substring to the display name byte-for-byte, with no glob interpretation. New unit coverage: a display name of * or containing ? no longer matches unrelated message bodies, but does match the literal character; the ordinary case (a plain display name at a word boundary) still matches.
  • P2 (PR #479 review): .m.rule.roomnotif and .m.rule.contains_ display_name were not spec-defined server defaults. CS API v1.19 §push-notifications, "Predefined Rules": "[Changed in v1.17]: the legacy default push rules that looked for mentions in the body of the event were removed." The current spec's complete "Default Override Rules" list has ten entries and does not include either rule_id; both were pre-m.mentions-module rules that scanned content.body for literal text (a display name, or "@room") — exactly the false-positive pattern .m.rule.is_user_mention/.m.rule.is_room_mention (structured m.mentions, added v1.7) replaced: a message merely containing someone's name, or the literal text "@room", as ordinary prose highlighted the recipient even when the sender's event carried no m.mentions at all and the correct mentions rule did not match. Both were previously added as server defaults (0.11.11) citing an Element SDK client-side warning and claiming they are "MUST per the Matrix v1.18 CS API" — that claim does not hold up against the checked-in v1.18/v1.19 spec text, so both are removed from default_push_ruleset.cpp. The contains_display_name condition kind itself remains spec-valid (deprecated, not removed) and its evaluator is unaffected — only the two default rules that fired it (and the @room body scan) unconditionally for every recipient are gone. New unit coverage (tests/unit/test_default_push_ruleset.cpp): the default ruleset no longer contains either rule_id; a message body naming the recipient or containing literal "@room" text still notifies via .m.rule.message but is never highlighted. Conformance and integration coverage updated to match (test_client_server_conformance.cpp's /pushrules/ default ruleset assertion; test_push_delivery_flow.cpp's display-name scenario, which now proves the highlight does NOT fire rather than that it does).
  • P2 (PR #479 review, client_server.cpp:6261): GET /rooms/{roomId}/context/{eventId} returned the room's current state, not "the state of the room at the last event returned" (CS API v1.19). When a room's name, membership, power levels, or other state changed after the event a client asked for context around, the response's state array leaked present-day values into what was supposed to be a point-in-time view — a documented deviation when /context landed, now fixed properly. New federation::resolve_state_event_ids_at() (event_query.hpp/.cpp) reuses the backward event-DAG walk build_state_response/ build_state_ids_response already use to answer the federation GET /state//state_ids endpoints (landed 0.8.10) rather than reimplementing state reconstruction, then folds the pinned event's own state contribution back in when it is itself a state event — the federation endpoints deliberately stop one step short of that (SS API GET /state: "prior to considering any state changes induced by the requested event"), while CS API /context wants the state inclusive of the last event returned. room_context_json() now tracks the event_id of the last event actually placed in events_after (falling back to the requested event itself when events_after is empty) and reconstructs state at that position instead of calling build_current_state_events_array(). New conformance coverage (test_client_server_conformance.cpp): a room's name changes strictly after the last event a bounded limit admits into events_after; state still reports the pre-rename name, not the room's present-day one. GET /messages was deliberately left unchanged and is now explicitly tracked in docs/todos/capability-gaps.md as a divergence rather than silently inconsistent: its state field is spec'd around chunk-relevance/ lazy-loading ("a list of state events relevant to showing the chunk"), not a DAG position, so this fix's shape does not apply there — it would need its own lazy-loading-aware implementation, which remains unstarted.
  • P2 (PR #479 review, config.hpp:107): the server.push.* config keys were undocumented. Push delivery defaults to disabled (server.push.enabled=false), and the only prior documentation change was a wildcard entry in the reloadability table — an operator had no discoverable way to turn on the delivery path this PR spent considerable effort building. docs/user-manual.md's configuration parameter reference gained a "Push notifications — server.push.*" table (enabled, connect_timeout_seconds default 10, total_timeout_seconds default 30, read from include/merovingian/config/config.hpp) and config/merovingian.conf.example gained a matching commented-out example section. While in there, server.oidc.* and server.identity_server.* were found to have the same gap (implemented, config-parseable, but never in the parameter reference table) — an earlier task had matched that precedent instead of fixing it. Both gained their own reference tables too (server.oidc.*'s reloadability was also missing from the reloadability policy table entirely; added as Reloadable, matching reload_policy.cpp's default fallthrough for keys with no explicit rule).

0.11.10

Four follow-ons to the 0.11.9 identity-server work: a discovery test-seam for hermetic IS mocking, bind/unbind/requestToken actually driving the remote IS, a positive-path 3PID-invite conformance test, and MSC4186 multi-list room-config combination.

  • Identity service: the bind/unbind/requestToken handlers now contact the remote identity server instead of persisting locally only. bind (POST /account/3pid/bind and the legacy POST /account/3pid) calls IS /3pid/bind and persists client_secret+sid (new 007_account_threepids_columns migration) so the binding can later be unbound. unbind (POST /account/3pid/unbind) and delete (POST /account/3pid/delete) recover client_secret+sid from the stored binding and call IS /3pid/unbind using IS auth mode 2 (sid+client_secret, no bearer); on IS non-support (no-support) the local binding is still removed, but a transport failure now fails closed (502) — silently removing the local record while the IS still holds the binding would orphan it (the user could never unbind it without the stored client_secret/sid). requestToken (register/account-3pid, email/msisdn) delegates sid-issuance to the IS when the request carries id_server+id_access_token; local validation is unchanged when they are absent (spec-compliant). A new request_msisdn_token IS client method is added. Security note: account_threepids.client_secret/sid are stored as TEXT (only populated for IS-bound 3PIDs); the threat and mitigations are documented in docs/threat-model.md. Observable change: unbind/delete on an unreachable IS now returns 502 instead of silently succeeding locally.
  • Discovery test-seam: IdentityServerClient::perform() resolves the IS host via CachedServerDiscovery::lookup_addresses (which rejects loopback) and never set trusted_ca_pem, so a self-signed local mock IS failed TLS — making hermetic IS tests impossible. A new test_forced_identity_resolution map on HomeserverRuntime (mirroring the existing test_forced_outbound_resolution contract) lets tests pin a local IS address and supply an in-memory CA bundle; perform() consults it before discovery. The seam struct lives in the identity header to keep the homeserver → identity dependency direction correct. Always empty in production; SSRF and TLS-trust behaviour is unchanged.
  • Conformance: a positive-path 3PID-invite scenario (tests/conformance/test_3pid_invite_conformance.cpp) proves that POST /rooms/{roomId}/invite with a trusted id_server contacts the IS store-invite and persists an m.room.third_party_invite carrying the IS-issued token as state_key (the prior scenario covered only the 403 fail-closed path). A bind/unbind round-trip integration test exercises the mode-2 unbind flow end to end via a mock IS.
  • Sliding sync (MSC4186): when a room is present in multiple list windows, the room configs now combine across all of them — required_state is the superset, timeline_limit is the maximum, include_heroes is OR'd — per MSC4186 room-config combination. Previously only the first matching list contributed (list-vs-subscription was already combined in 0.11.9; list-vs-list was the remaining gap).

0.11.9

Four capability-gap closures: remote 3PID identity-server lookup, MSC4186 required_state merge, admin-route rate limiting, and admin 401/403 consistency.

  • Identity service: a new identity module provides an outbound Identity Service API client (store-invite, lookup, bind, unbind, requestToken) that talks to a remote identity server over the SSRF-safe resolver with pinned addresses. POST /_matrix/client/v3/rooms/{roomId}/invite with id_server/medium/address now contacts the identity server's store-invite endpoint and builds the m.room.third_party_invite from the IS-issued token and public key, instead of minting them locally (local-only minting produced an unverifiable invite — the join-side third_party_signed signature is checked against the IS's key, which the IS never issued). The homeserver fails closed (403) when id_server is not an operator-trusted identity server. 3PID bindings are persisted (new 006_account_threepids.sql migration) instead of held only in memory. A new identity_server.* config block selects trusted identity servers, allowed bind domains, and timeouts. Note: the bind/unbind/requestToken handlers persist locally but do not yet drive the remote identity server — that IS sync is a documented follow-on (tracked in docs/todos/capability-gaps.md) pending a discovery test-seam for hermetic IS mocking.
  • Sliding sync (MSC4186): when a room is present in both a list window and a room_subscriptions entry, the combined required_state is now the superset (with dedup and wildcard-aware merge), the combined timeline_limit is the maximum, and include_heroes is OR'd — per MSC4186 room-config combination rules. Previously the subscription overrode the list wholesale.
  • Admin routes: /_merovingian/admin/* (health, metrics, audit, media moderation) were previously unreachable in production — the client listener only dispatches /_matrix/client/* and /_matrix/media/*, so admin routes 404'd on the public port and were only reachable via the test-only local router. They are now wired into handle_client_server_request and served on the client listener, dispatched to the local router before the general user-token gate so require_admin() owns the 401/403 split. They inherit the existing allow() per-IP/per-user/per-route rate limiter (throttled exactly once per request, no double-count); the /_merovingian/admin/ prefix is capped at 30/min per IP by default and is operator-tunable via client_rate_limits:. Security note: the admin surface is now exposed on the public client port — it remains auth-gated by require_admin() (401 for a missing/invalid token, 403 for a valid non-admin token) and rate-limited, so only operators with a confirmed admin session can use it. Counters remain in-memory by design — no per-request database write amplification; the trade-off (counters reset on restart) is documented in docs/http-transport.md.
  • Admin auth: /_merovingian/admin/* routes now return 401 for a missing or invalid token and 403 M_FORBIDDEN for a valid token belonging to a non-admin user, matching the /_matrix/client/v3/admin/* surface. Previously both cases returned 401.

0.11.8

Cross-device verification: notify a user's own devices when one of their devices uploads keys, and prompt a freshly-logged-in device to discover its own user's existing devices.

  • E2EE device keys: POST /_matrix/client/v3/keys/upload now records a device_lists.changed notification for the uploading user, so the user's other devices learn about new or updated devices and query /keys/query for them. This fixes the Element/Web verification hang where the receiving device received the m.key.verification.request to-device event but ignored it because it did not have the sender's device keys in its store.
  • E2EE device lists: POST /_matrix/client/v3/login now records a device_lists.changed self-notification when a genuinely new device is created. A new device's initial /sync therefore lists its own user in device_lists.changed, prompting the client to /keys/query and fetch the user's existing devices' keys on its very first sync — before the existing verified device even knows the new login exists. This closes the reverse-direction gap left by the key-upload fix: the new device (e.g. Element X) previously received an m.key.verification.request from the existing verified session but had not yet fetched the sender's device keys, so matrix-rust-sdk dropped the request ("Could not retrieve the device data ... ignoring it"). The spec sanctions the homeserver adding a user to device_lists.changed when a device's cached list may be stale; a brand-new device's cache is empty.
  • Tests: added unit scenarios covering same-user cross-device key upload, login-triggered own-device discovery, and the resulting device_lists.changed / /keys/query round-trip.

0.11.7

Sync to-device delivery observability and verification-request coverage.

  • Sync: added unit scenarios in tests/unit/test_sync_handler.cpp proving that a same-user m.key.verification.request to-device event is delivered to the target device on both legacy GET /_matrix/client/v3/sync and MSC4186 Simplified Sliding Sync (extensions.to_device).
  • Diagnostics: sync.response and sliding_sync.response logs now include to_device_count, device_changed_count, device_left_count, presence_count, account_data_count, and max_observed_sync_id, making it possible to verify from server logs whether a to-device message reached the client response.

0.11.6

Signing key lifecycle and startup hardening.

  • Crypto boundary: ensure_runtime_server_signing_key now rejects expired derived signing keys and automatically generates a fresh active key. This fixes a startup failure where the main process had a loaded signing secret but no usable active key, leaving crypto_provider null and causing GET /_matrix/key/v2/server to return 500.
  • Runtime startup: start_runtime now fails closed if the key-server response cache cannot be pre-warmed, instead of starting with a broken signing provider.
  • Tests: added unit scenarios covering expired-key rotation and startup recovery in tests/unit/test_homeserver_vertical_slice.cpp.
  • Tests: fixed tests/integration/test_federation_worker_flow.cpp by switching make_federation_worker_config from in-memory SQLite to a unique on-disk file. This matches how production persistence works across separate database connections so generated signing keys survive between the runtime and worker processes.
  • Tests: hardened worker seccomp scenario now skips when the test process is root. A root worker is killed by the filter (SQLite's fchown() and the capability bounding-set drop both fail), while production workers run as a non-root service user. The worker allowlist itself remains validated by unit tests.
  • Docs: updated docs/crypto-boundary.md to describe automatic rotation of expired keys.

0.11.5

Matrix spec v1.19 P2 gap closure.

  • Admin trust and safety: added tests/conformance/test_admin_safety_policy_rules_conformance.cpp covering GET/PUT/DELETE /_matrix/client/v3/admin/safety/policy_rules/{scope}/{entity} CRUD, 400 for invalid actions, 404 for missing rules, and 403 non-admin guards.
  • Federation keys: the server now supports multiple simultaneously-active Ed25519 signing keys. GET /_matrix/key/v2/server publishes every valid key in verify_keys, signs the response with the preferred key, and retires superseded keys to old_verify_keys. RuntimeMultiKeyEd25519Provider and RuntimeSigningKeyStore wire the multi-key behaviour through event signing and federation request signing.
  • Media: remote thumbnails now use the local sandboxed thumbnailing pipeline after fetching the remote file via the authenticated federation media endpoint.
  • Federation media: the authenticated GET /_matrix/federation/v1/media/download/{mediaId} endpoint now follows Location redirects in an SSRF-safe way instead of immediately falling back to the deprecated v3 endpoint.
  • Federation media: hardened the multipart/mixed parser for authenticated download responses to strict RFC 2046 semantics. Boundary delimiters must now sit on their own line, quoted/unquoted boundary tokens with optional whitespace around = are accepted, preambles before the first delimiter and LF-only transport padding are tolerated, and the parser fails closed unless exactly two parts are present.
  • Media thumbnails: added conformance coverage verifying that a remote thumbnail request never returns 200 with the full-size original bytes when federation infrastructure is unavailable.
  • E2EE keys: POST /_matrix/client/v3/keys/upload now reports one-time key counts per advertised algorithm, and backup session/room/batch deletion routes are verified end-to-end.
  • E2EE keys: GET /_matrix/client/v3/keys/changes now requires and validates both from and to sync tokens, filters changed/left results to the requested stream range, and returns M_MISSING_PARAM/M_INVALID_PARAM for missing or malformed tokens.
  • Profile: the Complement fixture tests/fixtures/complement/client_server_v1_19.json now covers PUT /_matrix/client/v3/profile/{userId}/avatar_url, its cross-user 403 guard, and a follow-on GET that verifies the stored value.
  • Room versions: added a runtime conformance scenario that creates a room for every stable version v1 through v12 and verifies each create event records the requested room_version via GET /rooms/{roomId}/state/m.room.create/.
  • Tests: updated the existing POST /keys/upload conformance test to expect the per-algorithm curve25519 count instead of the legacy hardcoded signed_curve25519 aggregate.
  • Third-party invites: POST /_matrix/client/v3/rooms/{roomId}/invite accepts same-server id_server/medium/address invites, generates a random token and Ed25519 keypair, and persists a signed m.room.third_party_invite state event. POST /_matrix/client/v3/rooms/{roomId}/join now accepts third_party_signed objects for matching same-server invites, verifies the embedded Ed25519 signature against the public keys in the invite event, creates the intermediate m.room.member invite event, and completes the join.
  • Room v12 / MSC4291: added conformance coverage for implicit create, creator privilege, and v12 event IDs; fixed any remaining edge cases.
  • Sliding Sync: expanded conformance fixtures and fixed subscription required_state merging with list-level defaults.
  • Federation membership: send_leave and send_knock now return stripped state, knock responses include knock_room_state, and a local accept-knock path promotes a knock to join.
  • Federation PDU verification: added conformance coverage proving parse_inbound_pdu_envelope and authorize_federation_pdu honour the room version for every stable version v1 through v12, including v12 create-event room-id derivation and the rejection of auth_events that list the implicit create event.
  • Tests: added unit scenarios E2EE /keys/upload groups one_time_key_counts by algorithm and Key backup deletion endpoints remove sessions, rooms, and all keys for the current version in tests/unit/test_client_server.cpp, extended backup route coverage in tests/unit/test_key_api.cpp, plus new unit and conformance tests for the other P2 areas.
  • Federation: added conformance coverage for GET /_matrix/federation/v1/hierarchy/{roomId} (200, 404, invalid suggested_only, missing-provider 501) and GET /_matrix/federation/v1/media/download/{mediaId} (200 multipart/mixed, 404, 451, missing-provider 501, percent-decoded mediaId) in tests/conformance/test_federation_space_media_conformance.cpp.
  • Sync: GET /_matrix/client/v3/sync now emits the summary object for every joined room with m.joined_member_count, m.invited_member_count, and m.heroes, per Matrix Client-Server API v1.19. Added tests/conformance/test_sync_summary_conformance.cpp to cover the new fields.
  • Trust and safety: added conformance coverage for POST /_matrix/client/v3/rooms/{roomId}/report/{eventId} (200 for valid reports, 400 for malformed bodies) and GET /_matrix/client/v3/admin/safety/reports in tests/conformance/test_safety_report_conformance.cpp.
  • Admin trust and safety review: added tests/conformance/test_admin_safety_review_conformance.cpp covering POST /_matrix/client/v3/admin/safety/review/{targetType}/{targetId} for room, media, and federation_server targets, empty review bodies, invalid target types (400 M_BAD_JSON), and non-admin guards (403 M_FORBIDDEN).
  • Read markers: POST /_matrix/client/v3/rooms/{roomId}/read_markers now rejects malformed bodies with 400 M_BAD_JSON. Added tests/conformance/test_read_markers_conformance.cpp covering 200 for m.read (with the marker appearing as an m.receipt ephemeral event in /sync), 403 M_FORBIDDEN for non-members, and 400 M_BAD_JSON for invalid JSON.
  • Presence: added tests/conformance/test_presence_conformance.cpp covering PUT /_matrix/client/v3/presence/{userId}/status 200 for valid updates, 403 M_FORBIDDEN for cross-user updates, and 400 M_BAD_JSON for malformed bodies.
  • Receipts: added tests/conformance/test_receipt_conformance.cpp covering POST /_matrix/client/v3/rooms/{roomId}/receipt/{receiptType}/{eventId} 200 for m.read (with the receipt appearing as an m.receipt ephemeral event in /sync), 400 M_INVALID_PARAM for invalid receipt types, 403 M_FORBIDDEN for non-members, and 400 M_BAD_JSON for malformed bodies.
  • Docs: updated docs/todos/capability-gaps.md and relevant spec docs; repaired a corrupted capability-ledger table row for Database persistence.

0.11.4

Matrix spec v1.19 P1 gap closure.

  • Client-server: GET /_matrix/client/v1/auth_metadata now returns RFC 8414 / Matrix v1.19 authorisation server metadata when the operator configures server.oidc.*. When OIDC is not configured it continues to return 404 M_UNRECOGNIZED as the spec requires for unsupported servers. No actual OAuth 2.0 flow is implemented yet; this change covers discovery only.
  • Federation: added validate_federation_tls_origin() in federation/server_discovery to enforce the TLS-certificate-identity contract from Matrix Server-Server API v1.19 §2. The helper validates direct-name matches, well-known delegation consistency, and rejects IP-literal destinations unless the original server name is the same IP literal (including IPv6 literals).
  • Tests: added tests/unit/test_auth_oidc_discovery.cpp, tests/unit/test_federation_tls_origin.cpp, and tests/unit/test_config_oidc_validation.cpp; extended tests / conformance / test_server_discovery.cpp and tests / unit / test_client_server.cpp with configured - OIDC, disabled - OIDC, and TLS - origin scenarios.- CI : updated .gitleaks.toml to allowlist the imported docs / matrix - v1.19 - spec / copy, matching the existing v1.18 allowlist.- Docs : added a "Why C++ and not Rust?" section to README.md explaining the memory - safety tradeoff and how it's mitigated (banned unsafe primitives, isolated libsodium boundary, BSD platform reach, continuous sanitizer/fuzz verification).

    ##0.11.3
    
    Matrix spec v1.19 behaviour
    changes(Phase 3)
        .
    
    - Federation : per -
                   room server ACL enforcement(MSC4436 / Matrix v1.19).Added `federation
                       / server_acl` evaluator with case -insensitive glob matching,
    

    IP - literal handling, and port stripping .Inbound federation requests to protected endpoints(make_* / send_ * / invite / backfill / state / state_ids / get_missing_events / space_hierarchy) are now rejected with 403 M_FORBIDDEN when the origin server is denied.PDUs inside PUT / _matrix / federation / v1 / send / {txnId} are checked per - PDU against both the transport origin and the PDU sender's homeserver; room-local EDUs (m.typing, m.receipt) are checked against the transport origin. Rooms with no m.room.server_acl event continue to allow all servers. - Client - server: encrypted history sharing conformance coverage(MSC4268).The generic PUT / _matrix / client / v3 / sendToDevice / {eventType} / { txnId } plumbing already accepted any to - device type; a conformance test now verifies that m.room_key_bundle messages carrying the m .history_not_shared withheld code are delivered to the target device and retain their event type and content.- Tests : added tests / unit / test_federation_server_acl.cpp covering glob matching, case -insensitivity, ? and * wildcards, IP - literal denial, port stripping, deny - before - allow ordering, and default allow_ip_literals behaviour .

    ##0.11.2
    
    Matrix spec v1.19 behaviour
    changes(Phase 2)
        .
    
    - Client - server:
    

    GET / _matrix / client / v3 / publicRooms order is now server - defined per Matrix v1.19(MSC4423).The implementation already returned rooms in insertion order; a conformance test now documents and protects that server - defined order.- Client - server : m.key_backup account data(MSC4287) is now accepted, stored, and retrievable via the existing PUT / GET / _matrix / client / v3 / user / {userId} / account_data / { type } endpoints.- Client - server : custom emoji / image packs(MSC2545) are now accepted: m.room.image_pack state events and m.image_pack.rooms global account data round-trip through the existing event and account-data paths. - Database: schema version 4 adds a state_transitions table (via migrations/004_state_transitions.sql) so the server can record the previous state event for every (room_id, event_type, state_key) tuple. The client event builder uses this to inject unsigned.replaces_state into state events returned by /sync, /rooms/{roomId}/messages, /rooms/{roomId}/members, and other state-event paths, matching the Matrix v1.19 client event format. - Client-server: centralized the client-event serializer so /sync, /rooms/{roomId}/messages, GET /rooms/{roomId}/state, and GET /rooms/{roomId}/event/ { eventId } all use the same helper and produce identical event_id/unsigned.replaces_state handling. Removed the duplicate serializer that previously existed in client_server.cpp. - Tests: fixed malformed raw-string literal in the m.room.image_pack conformance and unit fixtures that caused the PUT-state request body to be invalid JSON. - Database: schema version 5 backfills state_transitions from existing current_state rows via migrations/005_backfill_state_transitions.sql so rooms created before the v4 migration do not lack transition history. - Database: state_transitions now has an in-memory hash index, and client_event_with_id() uses it for O(1) lookups instead of scanning the whole vector when injecting unsigned.replaces_state. - Events: for federated state events, prepare_store_event_with_state() now derives previous_event_id by walking the event's prev_events/auth_events graph to find the actual predecessor state event. It no longer assumes the local arrival-time current_state entry was the predecessor, which was wrong on forks. - Client-server: GET /_matrix/client/v1/mutual_rooms now issues opaque server-signed pagination tokens instead of integer next_batch values. The tokens are keyed to the deployment and verified on decode, so clients cannot guess or forge pagination positions. - Database: the migration runner now accepts data-only migration statements (INSERT/UPDATE/DELETE) as valid no-op schema-state transitions. The v5 state_transitions backfill is the first data-only migration; without this fix the runner rejected it and runtime startup failed.

    0.11.1

    Maintenance : migrate repository documentation and tooling from Matrix spec v1.18 to v1.19. This is a mechanical update only; no v1.19 behaviour changes are implemented in this release.

  • Regenerated local spec docs under docs/matrix-v1.19-spec/ from docs/matrix-v1.19-spec/index.md

  • Added generated docs/matrix-v1.19-client-server-api.md (+ appendix) from the v1.19 OpenAPI definition.
  • Updated scripts/fetch_matrix_spec.py, scripts/generate-matrix-v119-spec-doc.mjs (renamed from v118), scripts/repoint_spec_links.py, and scripts/check-conformance-gate.sh to v1.19.
  • Repointed all internal Markdown/source spec links to the v1.19 local docs.
  • Renamed test Complement fixtures from *_v1_18.json to *_v1_19.json.
  • v1.18 spec docs remain in place for comparison.

0.10.63

Fix: legacy /sync never reported unread notification counts, so read receipts appeared to do nothing.

  • Sync: the classic GET /_matrix/client/v3/sync response's rooms.join.{roomId} object never included the spec-required unread_notifications block (notification_count/highlight_count). Receipts posted via POST /rooms/{roomId}/receipt/{receiptType}/{eventId} and POST /rooms/{roomId}/read_markers were correctly stored and echoed back as ephemeral m.receipt events, but nothing in the legacy sync path recomputed or surfaced the notification counts those receipts should have cleared. Clients that poll /sync (rather than the unstable MSC4186 sliding-sync endpoint) — including Element — therefore had no server-authoritative signal that reading a message cleared its unread state, and the client's unread badge only cleared via its own local heuristic (e.g. sending a reply). count_notifications/count_highlights, previously local helpers in sliding_sync_room_builder.cpp used only by sliding sync, are now shared (declared in include/merovingian/sync/sliding_sync_room_builder.hpp) and used by both sync paths; the legacy /sync room builder now computes the same receipt-baselined counts via sync::read_receipt_ordering and includes them in every rooms.join entry.

0.10.62

Security fixes for issues #460-#463 (4 findings from a security review of the codebase), plus a live-incident fix (issue #464).

  • Media (issue #460, HIGH): GET /_matrix/client/v1/media/download/{serverName}/ { mediaId } and GET / _matrix / client / v1 / media / thumbnail / {serverName} / { mediaId } were dispatched before the access-token auth gate, allowing unauthenticated media retrieval by media ID. The v1 routes are now matched and dispatched after the auth gate (line 7774), while the unauthenticated v3 routes remain pre-auth as the spec requires.
  • Federation (issue #461, HIGH): handle_send_membership (send_join/send_leave/send_knock) accepted inbound PDUs with no Ed25519 signature verification, content-hash check, or origin/sender-domain check — a federated server with valid X-Matrix credentials could forge any user's membership. The handler now runs the same verification pipeline as the transaction/PDU path: authorize_federation_pdu (signature + key resolution), verify_pdu_content_hash, and sender_domain == request.origin assertion, rejecting on any failure.
  • Federation (issue #462, HIGH): handle_invite accepted invite events with no signature verification and no origin/sender check, then re-signed the forged event with the victim's own key. The handler now verifies the inviting server's signature via authorize_federation_pdu, checks verify_pdu_content_hash, and asserts sender_domain == request.origin before dispatching to the invite handler. InviteRequest gained an origin field so the handler can enforce the sender/origin match.
  • Observability (issue #463, HIGH): four log_diagnostic_audit call sites passed req.access_token (the raw bearer token) as the actor argument, persisting valid access tokens to the diagnostic log, in-memory audit_events, and the persistent audit_log DB table. All four call sites now pass "<unknown>" as the actor, mirroring the established pattern in auth_service.cpp:763.
  • Federation (issue #464, MEDIUM): the m.direct_to_device EDU handler (local_http_router.cpp) discarded the per-device enqueue_to_device_message result with std::ignore and always returned EduDispositionStatus::accepted, so a megolm room-key share that failed to persist (e.g. a malformed or empty field on one device entry) was dropped with zero trace — the transaction still ack'd 200, edu_dispatched still counted it, and nothing in any log said a share was lost. Diagnosed from a live report of a verified device unable to decrypt messages/media federated in from a Synapse-hosted room ("Unable to decrypt: unknown inbound session"). enqueue_direct_to_device_messages now returns per-envelope targeted/stored counts; the sink returns rejected_invalid and logs a federation.edu.direct_to_device.store_incomplete warning (origin, targeted, stored — no key material) whenever a targeted device's message did not persist.

CI fixes

  • The #463 fix's first attempt added a blanket rule to contains_sensitive_marker redacting every log field literally named actor, regardless of value. That breaks src/observability/AGENTS.md's requirement to log user_id/device_id for authenticated request traces and is unnecessary: the actual vulnerability (raw tokens passed as the actor value) is already closed at the 4 call sites above, and both #463 regression tests assert on the persisted audit_events.actor value, not this redaction rule. Removed the blanket key-based rule; legitimate actor identifiers (e.g. @alice:test) now survive diagnostic_log_summary again.
  • tests/federation_signing_test_support.hpp's new make_signed_event_json() test helper signed events without ever computing/attaching hashes.sha256 (that step is the caller's responsibility in production code — see room_service.cpp's compose_event/handle_send_join/handle_send_leave — but the helper's own doc comment incorrectly claimed it did this). Every "valid signature" fixture built through it therefore failed verify_pdu_content_hash, breaking 9 send_join/invite test scenarios. The helper now computes and attaches the content hash before signing, matching the production sequence exactly.
  • make_signed_fallback_key_json() had a typo dropped from a prior edit — "signatures":" instead of "signatures":{" — producing structurally invalid JSON in every fallback-key fixture built through it, breaking the E2EE key-upload persistence integration test with M_BAD_JSON. Restored the missing {.
  • Three pre-existing test_federation_membership_endpoints.cpp scenarios (two send_join auth_chain tests, one v2-invite-path-parser test) built their request PDUs as hand-written JSON literals with no real signature and a placeholder "hashes":{"sha256":"x"} — valid before #461/#462 added inbound PDU signature/hash verification to send_join and invite, no longer valid after. Updated their fixtures to use the (now-fixed) make_signed_event_json() helper and a sender domain consistent with the registered remote signing key, so they exercise the new verification path instead of being rejected by it.

0.10.61

Fixes for the remaining open issues #411-#457 (29 findings).

  • Events (issue #411, HIGH): the state-resolution mainline was built from the head power-levels event's direct auth_events — all of them, regardless of type — and mainline_compare defaulted unmatched events to position 0 (the newest anchor). Per rooms/v10 Mainline ordering, the mainline is now only m.room.power_levels events walked transitively (P(i+1) = the PL event in Pi's auth_events), positions are found by walking each event's PL ancestry transitively, and an event with no mainline ancestor gets the ∞ sentinel (sorts first/oldest). auth_events is also now read as the v3+ array-of-ids shape (previously parsed as an object, so the walk never saw real auth events at all). Reverse-topological and mainline orderings both gained the spec's event_id tie-break.
  • Events (issue #424, MEDIUM): resolve_state_v2 sorted all conflicted events with the reverse-topological power ordering, then immediately re-sorted the whole list by mainline. It now follows the spec's algorithm steps 1-4: power events (per the spec definition, now implemented as is_power_event) are sorted by reverse-topo power ordering and auth-checked first; only the remaining events are mainline-ordered against the partially resolved power levels and auth-checked second.
  • Federation (issue #414, HIGH): sender_domain split the sender user ID on the last colon, so @user:example.com:8448 resolved to sender domain "8448" — every PDU from a homeserver with a port-suffixed server name failed signature lookup and was dropped. User IDs are now split on the first colon via the new auth::user_id_server_name identifier-grammar helper.
  • Federation (issue #423, MEDIUM): FederationRuntimeState::audit_events grew without bound (one entry per federation decision) and federation_audit_is_safe scanned the whole vector per call. Converted to a capped (10,000-entry) FIFO deque with an incrementally maintained unsafe-reason counter, making the safety check O(1).
  • Sync (issue #417, MEDIUM): sliding-sync notification_count/highlight_count counted events since the sync position (so an initial sync reported every message ever sent as unread) and the by_notification_count room-list sort ignored receipts entirely (its receipt scan was dead code). Both now baseline on the user's last m.read/m.read.private receipt via the new read_receipt_ordering helper, and the user's own events no longer count as unread.
  • Sync (issue #456, LOW): stream-token decode_component had no length cap, so a 17+-hex-digit component wrapped uint64_t — a crafted pos token could decode to an arbitrarily small ordering and defeat the pos never-regress guarantee. Components longer than 16 hex digits are now rejected.
  • Sync (issue #457, LOW): client_event_json appended event_id without checking whether the stored JSON (v1-v3 rooms, foreign-server PDUs) already carried one, producing duplicate keys. Any existing event_id member is now replaced.
  • Config (issue #421, MEDIUM): build_reload_plan emitted no diff for server.cors.*, server.turn.*, security.secrets.master_key_file, security.trust_safety.*, token lifetimes, federation.worker.*, log_modules.*, or client_rate_limits.* — edits to those keys were reported as "no changes" and silently dropped. All blocks now diff (maps entry-by-entry); security.secrets.master_key_file and federation .worker.* are flagged restart_required (loaded / spawned at startup) . http::RateLimitPolicy gained a defaulted operator== for the map diffs.- Config(issue #422, MEDIUM) : RuntimeConfigSnapshot::apply_reload mutated the live Config in place with no synchronization while current() advertised concurrent - reader safety.The snapshot is now an immutable shared_ptr<Config const> swapped under a mutex; current() returns the shared snapshot.
  • Homeserver (issue #431, MEDIUM): policy_server_timeout seconds were multiplied by 1000 in uint32_t, wrapping for values above ~49.7 days. The millisecond value is now computed in 64 bits and saturated at the uint32_t ceiling.
  • Config (issue #455, LOW): parse_i64_value accumulated the magnitude in a signed 64-bit value and rejected INT64_MIN. The magnitude is now accumulated unsigned with a sign-aware limit, so the full i64 range parses and out-of-range literals are still rejected.
  • Core (issue #426, MEDIUM): the sync timeout query-parameter parser had no overflow guard — an overlong decimal wrapped modulo 2^64 into an attacker-chosen effective timeout for the sync pool. Now overflow-checked; overflowing values are discarded.- Core(issue #440, LOW) : percent_decode/percent_decode_path_component silently mapped invalid hex(e.g. % ZZ) to NUL bytes.Malformed escapes are now kept literal.- Core(issue #439, LOW) : close_from_directory duplicated the walk fd without adding the dup to the keep set — on Linux the dynamic / proc / self / fd sweep closed the dup mid - iteration and its RAII owner closed the same fd again.The purposeless dup is removed.- Media(issue #429, MEDIUM) : decoder_policy computed max_upload_bytes * max_decompression_ratio unsaturated(its sibling worker_plan already saturated); extreme config wrapped the decode budget to a small value.Both now share a saturating_scale helper.- Media(issue #446, LOW) : thumbnail requests fell back to serving the full - size original when thumbnailing was disabled , the worker was missing , or generation failed — a bandwidth - amplification vector(a 32x32 request answered with a multi - megabyte blob) .The endpoint now returns 404(thumbnails unavailable / thumbnail generation failed) instead.- Media(issue #449, LOW) : the thumbnail worker's per-axis 4096 cap still admitted a 4096x4096 RGBA output buffer (64 MiB), and a crafted IHDR could stack allocations toward the RLIMIT_AS ceiling. A worker-imposed ~4.1 Mpixel budget now applies to both the decoded frame and the resampled output, and requests cannot raise it. - Canonical JSON(issue #430, MEDIUM) : make_signable_object_view did not strip signatures/unsigned despite src / canonicaljson / AGENTS.md claiming it did.It now elides both top - level keys per the spec's Signing JSON procedure; docs/canonical-json.md updated to match. - Canonical JSON(issue #435, LOW) : format_double used snprintf/strtod , both locale - dependent — under a comma - decimal LC_NUMERIC locale floats serialized as 1 , 5 (invalid JSON) .The formatter now normalizes the active locale's decimal separator back to . (kept snprintf-based because std::to_chars for doubles is unavailable on several supported toolchains). - Crypto(issue #433, LOW) : IpcStreamCipher::decrypt ignored the secretstream tag , silently accepting TAG_FINAL/TAG_REKEY as normal messages.Any tag other than TAG_MESSAGE is now rejected.- IPC(issue #451, LOW) : IpcChannel::build_frame emitted { "id" : 1, } (trailing comma — invalid JSON) for a body of exactly { }, which would make the peer mark the channel unhealthy.Empty - object bodies now produce a well - formed frame.- Auth(issue #438, LOW) : the / devices / { deviceId } route matcher was a bare prefix check, matching / devices / (empty id) and / devices / foo / bar (extra segments) .It now requires a single non - empty id segment, consistent with the key - API matcher.- HTTP(issue #441, LOW) : outbound response header values kept trailing optional whitespace; RFC 7230 §3.2.4 trailing OWS is now stripped alongside leading OWS.- Homeserver(issue #452, LOW) : exact - equality route matches compared the raw target(including the query string) — POST / logout / all ? ... 404'd — and the / sync prefix match over - matched / syncXYZ.Route matching now compares the path component(target_path helper); /_matrix/key/v2/server in the local router likewise.
  • Homeserver (issue #453, LOW): is_federation_send_target scanned the whole target (including the query string) for the /send/ prefix, letting ?x=/_matrix/federation/v1/send/y misroute a request past the worker. Now matches the path component only.
  • Homeserver (issue #454, LOW): the admin review/policy-rule path parsers kept the query string and never percent-decoded, persisting rules under entities like @alice:example.org?notify=1 that never match later lookups. Both parsers now strip the query and percent-decode the id segment.
  • Database (issue #447, LOW): SQLite connections now pin PRAGMA synchronous = FULL and PRAGMA journal_mode = DELETE at open, so environment-level default pragmas cannot silently weaken durability.
  • Database (issue #448, LOW): media_blobs.bytes and server_signing_keys.secret_key were TEXT columns (and secret_key nullable); binary payloads containing NUL also truncated on reload because the SQLite reader used a C-string constructor. Columns are now BLOB (secret_key NOT NULL DEFAULT '', folded into 001_initial_schema.sql per pre-1.0 migration policy) and column reads are length-based.
  • Tests: new/updated unit and conformance coverage for every fix above, including state-resolution mainline conformance scenarios, an identifier-grammar scenario for port-suffixed server names, receipt-baselined notification-count scenarios, a concurrent-reader reload scenario, and a NUL-byte media blob round-trip.

0.10.60

Security fixes for issues #409-#450 (21 findings from a security review of the codebase).

  • Authorization (issue #409, HIGH): kick/ban/unban in events/authorization.cpp only checked the sender's power against the kick/ban level, never target_power < sender_power — a power-50 moderator could kick or ban a power-100 admin. Kick/unban now follows spec rule 5 exactly (unban requires meeting both the ban and kick levels, and outranking the target); ban now requires meeting the ban level and outranking the target (rule 6.2).
  • Authorization (issue #410, HIGH): m.room.redaction was authorized against redact/ban power levels (sender_power >= redact_level || sender_power >= ban_level) instead of the generic events["m.room.redaction"]/events_default path every other message event uses — both over- and under-enforcement were possible depending on room config. Redactions now fall through to the standard message-event power check; redact/ban remain relevant only to * applying * an already - authorized redaction, not authorizing it.- HTTP(issue #412, HIGH) : RateLimitEngine::check() returned allowed = true when both the per - IP and per - user policies resolved to nullopt ( e.g.an operator- configured policy with window_seconds> 3600, which fails validation) — a misconfigured policy silently disabled rate limiting.Now fails closed , matching the standalone request_is_rate_limited() helper; config.cpp also now rejects window_seconds > 3600 at config-validation time.
  • HTTP (issue #427, MEDIUM): rate-limit bucket tables (m_ip_buckets/m_user_buckets) were unbounded std::vectors scanned linearly per request — an attacker rotating a client-supplied X-Forwarded-For value could grow the table and the per-check cost without bound. Switched to std::unordered_map with a 100,000-entry cap and stale/oldest-entry eviction.
  • HTTP (issue #413, HIGH): mero_curl_write_header was noexcept but performed unbounded allocation with no cap on header count or size — a hostile federation peer streaming enough response headers could trigger a bad_alloc that calls std::terminate and takes down the whole process. Header storage is now capped (256 headers / 64 KiB) and wrapped in try/catch.
  • Homeserver (issue #415, HIGH): resolve_policy_server_hook() (a synchronous outbound HTTP call to the configured policy server) was invoked while handle_federation_http_request still held runtime.mutex, so a slow or unreachable policy server froze every other consumer of that mutex — effectively the whole process — for up to policy_server_timeout. The hook now runs after the guard is released.
  • Federation (issue #416, HIGH): FederationRuntimeState::accepted_transactions grew without bound — every accepted transaction with a distinct transaction_id was appended and never evicted. Converted to a capped (10,000-entry) FIFO ring.
  • Media (issue #418, MEDIUM): the internal media pipe body hardcoded scanner_clean to the literal "clean" regardless of any scan result, making the quarantine-on-infection path structurally unreachable even with security.media.enable_av_scanner=true. client_server.cpp now runs a deterministic EICAR-test-signature check and threads the real verdict through (Merovingian does not integrate a real AV engine — this makes the existing policy-hook design actually live for the one universally-recognized scanner-integration test case).
  • Crypto (issue #419, MEDIUM): IpcStreamCipher's secretstream State (holding the derived session keys) was never zeroized on destruction. Added a State destructor that sodium_memzeros both directions.
  • Crypto (issue #432, LOW): crypto_kx_keypair's return value was ignored in IpcStreamCipher's constructor; a failure would send an uninitialized public key to the peer and derive session keys from an uninitialized secret key. Now checked and fails closed.
  • Homeserver (issue #420, MEDIUM): the audit-sink database pointer is thread_local, installed only on the thread that constructs HomeserverRuntime (main) — HTTP handlers run on ThreadPool worker threads, where the sink silently no-op'd (e.g. every registration_policy.denied audit row was dropped in production). ThreadPool gained an on_thread_start hook, wired in main.cpp to install the audit database on every worker thread in both HTTP pools.
  • Federation (issue #425, MEDIUM): the typing EDU handler never checked that content.user_id's domain matched the verified envelope origin (a cross-origin identity spoof), and extracted room_id/user_id by scanning for the next " instead of parsing JSON (an escaped quote in user_id truncated the extracted value). Now validates the domain via user_belongs_to_origin() and parses with the canonical JSON parser.
  • Platform (issue #428, MEDIUM): __NR_personality was unconditionally in the seccomp allow-list (for ThreadSanitizer's personality(ADDR_NO_RANDOMIZE) call) in both the main and worker filters, even in production builds — letting code with arbitrary-write RCE disable ASLR. Gated behind __has_feature(thread_sanitizer) / __SANITIZE_THREAD__ in both filters.
  • Auth (issue #434, LOW): password_matches/registration_token_matches passed string_view::data() to crypto_pwhash_str_verify, which requires a null-terminated C string — safe today only because every caller passes a std::string. Now copies into a std::string first.
  • Auth (issue #436, LOW): hash_access_token_v2 is an unkeyed crypto_generichash — a DB leak allows offline rainbow-table recovery of token plaintexts. Still used as a fallback when no master key or signing key is configured; issuing a token under this fallback now logs a token.unkeyed_hash_fallback warning so operators notice the degraded mode.
  • Auth (issue #437, LOW): redacted_token_for_log disclosed the exact token byte length, a minor side channel distinguishing token versions and valid- from invalid-length presented tokens. Now discloses only a coarse size bucket (tiny/short/medium/long).
  • Database (issue #442, LOW): PostgreSQL's create_table_if_missing_sql concatenated table.name/columns_sql into DDL with no identifier validation or quoting, unlike the SQLite path. Now applies the same schema_table_is_core validation and quote_sqlite_identifier quoting.
  • Media (issue #443, LOW): media_id_is_valid rejected / and .. but not embedded spaces, diverging from media_id_is_safe's stricter check. Unified to reject the same shapes.
  • Homeserver (issue #444, LOW): the download/thumbnail route parser (local_media_download_parts) accepted .. and embedded spaces in the media_id path segment, caught only later at the repository boundary. Now rejects the same shapes the admin media routes already do.
  • Media (issue #445, LOW): sniff_mime_type's text/plain heuristic classified any all-printable-ASCII blob as text/plain, including an ASCII HTML/JS polyglot — since text/plain is in the default MIME allow-list, a declared-text/plain polyglot upload had no declared-vs-sniffed mismatch to catch it. Content that opens with < (after leading whitespace) is no longer classified as text/plain.
  • Homeserver/federation (issue #450, LOW): documented (in docs/threat-model.md and at both call sites) that main's pdu_sink does not independently re-verify a PDU's Ed25519 signature before persisting it — it relies on the worker (or the transaction handler, for the non-worker path) having already verified via authorize_federation_pdu. Accepted as a defense-in-depth gap given the existing worker-trust model; full re-verification would require plumbing the raw PDU and a main-side-resolved key through a different envelope shape than InboundPduEnvelope provides today.
  • Tests: new/updated unit, integration, and conformance coverage for every fix above, including a new tests/unit/test_ipc_stream_cipher.cpp (previously no test coverage existed for IpcStreamCipher at all).

0.10.59

  • Security/crypto-boundary (issue #396): consolidate all libsodium access in src/crypto/, src/auth/, src/events/, and src/core/secret_buffer.cpp; src/ipc/channel.cpp now uses crypto::IpcStreamCipher for KX + secretstream.
  • Hand-rolled JSON scanners (issues #397, #401, #403): replace worker_pool.cpp json_get_u64, federation_request_routing.cpp /send room_id extraction, and ipc_ed25519_provider.cpp sign_response parsing with canonicaljson::parse_json() and typed accessors.
  • Backfill query hardening (issue #399): parse_backfill_query() checks errno == ERANGE after std::strtoull() and rejects out-of-range limit values.
  • File-descriptor close path (issue #400): close_all_file_descriptors_except() checks errno != 0 after std::strtol() on /proc/self/fd entries and rejects overflowed names.
  • Sync-pool plain-socket writes (issue #402): add a looped send_all() helper so short ::send() writes on the sync-pool plain-socket path are retried instead of truncated.
  • Constant-time comparison (issue #405): crypto::constant_time_equal_variable_length() now returns false if any libsodium crypto_generichash_* step fails, eliminating the fail-open all-zero digest path.
  • Key-backup version enforcement (issue #404): modifying session endpoints require the requested ?version= to match the current backup version and return 403 M_WRONG_ROOM_KEYS_VERSION with current_version when it does not; GET endpoints filter by the requested version and default to the current version when it is omitted; delete_all_key_backup_sessions now respects a version parameter.
  • Registration token loading (issue #406): read token file directly into core::SecretBuffer, fail closed when mlock fails, and hash via auth::hash_registration_token.
  • Update scripts/reject-unsafe.sh and docs/security-coding-rules.md to encode the permitted libsodium boundary and reject regressions.
  • Add unit and conformance tests covering the key-backup version enforcement paths.
  • Add unit tests for the new crypto-boundary helpers (secure_random_bytes, secure_random_hex, to_hex, base64, generic_hash, ed25519_sign_detached, ed25519_verify, RuntimeEd25519Provider) and auth helpers (token_secret_has_required_entropy, token_hash_is_persistable, access-token v2/v3/v4 hashing, password_matches, registration-token hashing).

0.10.58

Changed

  • docs: restructure README.md. Reordered around a fixed top-level structure — latest-release line, badges, table of contents, "What is Merovingian and what makes it special?", Release Artifact Verification, Installation and Configuration, Starting Merovingian for the first time, Upgrading Merovingian, Troubleshooting, and Getting Started With Development. The former "Project Status" and "Secure by design" sections were folded into the new "What is Merovingian" section; new Installation/Configuration, first-start, upgrade, and troubleshooting sections summarize and link out to the corresponding docs/user-manual.md sections rather than duplicating them.
  • docs: rewrite the "What is Merovingian and what makes it special?" section to make the security case explicitly, adding a "Why choose Merovingian over other homeservers" subsection grounded in concrete, existing mechanisms: the mechanically-enforced ban on raw new/delete/malloc/free/raw pointers, seccomp/pledge/Capsicum-sandboxed worker processes for untrusted media decoding and federation traffic, fail-closed startup self-checks and signature/config validation, mlock()'d/auto-zeroising secret storage with constant-time comparisons, narrow crypto module isolation, default-quarantine media handling and architectural opacity of E2EE attachments, redaction-aware structured logging with durable security audit rows, and continuous sanitizer/fuzzing/static-analysis/secret-scanning CI gates — with links out to docs/threat-model.md, docs/hardening.md, docs/crypto-boundary.md, and docs/security-coding-rules.md for the full detail.
  • docs: fix an inaccurate claim in the opening paragraph of the same README section. It previously said the homeserver "holds every private conversation, encryption key, and access token" — wrong: Matrix's E2EE model keeps clients' private encryption keys and plaintext entirely client-side, and the homeserver only ever sees ciphertext for encrypted rooms (plus public device/one-time-key material and, if key backup is used, client-encrypted backup blobs it cannot read). Reworded to scope the server's actual exposure correctly: federation identity, access tokens, message metadata, and plaintext only for rooms that aren't end-to-end encrypted.

0.10.57

Added

  • feat(events): implement third-party (3PID) invite authorization — Matrix spec rule 4.3.1. An m.room.member event with membership: "invite" and a content.third_party_invite property was previously authorized by the generic invite path (target-not-joined/banned, sender-joined, invite-power), silently ignoring the 3PID token and signature entirely — MembershipPolicy::third_party_invite and AuthEventMap::third_party_invite existed in the type but no production code path ever set or checked them. authorize_event_against_auth_events now implements the full spec sub-tree for this content: reject if the target is banned; reject if signed is missing, or missing mxid/token; reject if signed.mxid != state_key; reject if no m.room.third_party_invite state event exists with state_key == signed.token; reject if that event's sender doesn't match the invite event's sender; and finally verify an Ed25519 signature in signed.signatures against content.public_key/public_keys on the m.room.third_party_invite event (payload is the canonical {mxid, sender, token} object, matching ordinary Matrix "Signing JSON"). Also implements the separate rule 6 — creating an m.room.third_party_invite event itself is now gated on the room's invite power level, not the generic state_default power every other state event uses.
  • feat(crypto): add crypto::ed25519_verify — a stateless Ed25519 verification entry point. Unlike Ed25519Provider::sign, verifying an arbitrary public key needs no signing-key store, so this is a plain free function in crypto/ed25519.hpp/.cpp wrapping crypto_sign_verify_detached, usable from pure modules (events/authorization.cpp) without threading a provider through every call site. RuntimeEd25519Provider::verify (homeserver/runtime.cpp) now delegates to it instead of duplicating the libsodium call.
  • feat(events): wire AuthEventMap::third_party_invite population into all three real authorization call sites — room_service.cpp (local event composition), local_http_router.cpp (inbound federation PDU ingestion), and state_resolution.cpp (state resolution v2) — each now extracts content.third_party_invite.signed.token from the event under authorization and looks up the matching m.room.third_party_invite state event by state_key.

Testing

  • test(events): add 10 conformance scenarios for third-party invite authorization covering: a valid signature (both the legacy content.public_key and the content.public_keys list forms), a banned target, a missing signed property, an mxid/state_key mismatch, no matching m.room.third_party_invite event, a sender mismatch, a forged signature, and the separate invite-power-vs-state_default gating rule for m.room.third_party_invite creation. test_event_auth_rules.cpp, using real Ed25519 keypairs via tests/federation_signing_test_support.hpp (authorization now does real cryptographic verification, not a fake provider double).

Notes

  • Accepting this shape end-to-end — POST /invite with a 3PID address/id_server, and third_party_signed on /join — remains unimplemented. The former needs a real identity-server HTTP client (otherwise there is no real party to source public_key/public_keys from); the latter needs the PUT /_matrix/federation/v1/exchange_third_party_invite/{ roomId} endpoint or same-server authority to sign an intermediate invite event on behalf of the original inviter (a different sender than the joining user) — both larger, separate efforts tracked in docs/todos/capability-gaps.md. The auth-rule engine above already validates either shape correctly whenever such an invite event exists in room state (e.g. created via the generic PUT /rooms/{roomId}/state/m.room.third_party_invite/{ token} endpoint, or received over federation).

0.10.56

Fixed

  • fix(observability): redact a bare token query parameter in structured logs. contains_sensitive_marker recognized access_token/refresh_token/session_token and a handful of exact keys, but never the bare key token. GET /_matrix/client/v1/register/m.login.registration_token/validity?token=<secret> passes the plaintext registration token as a token query parameter, and every request target is logged via sanitized_http_target, so the raw secret was written to structured logs in cleartext. token is now in the exact-match redaction list.

  • fix(auth): stop requiring an access token on POST /_matrix/client/v3/refresh. The spec is explicit: "this endpoint does not require authentication via an access token. Authentication is provided via the refresh token." client_auth_endpoint_requires_access_token excluded only login and register_account, so a client whose access token had already expired — the exact case /refresh exists to recover from — would be rejected before the refresh token in the body was ever inspected. refresh_token is now excluded alongside login and register_account.

  • fix(net): mark accepted client sockets close-on-exec. http_server.cpp's plain-HTTP and TLS accept loops used ::accept() instead of accept4(..., SOCK_CLOEXEC), unlike every other fd-creation site in the codebase (tcp_acceptor.cpp, shutdown_signal.cpp, worker_supervisor.cpp). Because the homeserver spawns worker subprocesses (federation workers, the thumbnail worker) via posix_spawn/fork() while client connections — including long-poll /sync — remain open, an accepted socket without FD_CLOEXEC would be inherited by every subsequently spawned worker for as long as the connection stayed open, leaking live client sockets into unrelated child processes. Both accept loops now use accept4(..., SOCK_CLOEXEC).

  • fix(canonicaljson): fail closed on floats in the signing/hashing serialization path. serialize_canonical()'s float branch used std::to_string(double), which is fixed to 6 fractional digits, not the shortest round-tripping representation the surrounding trim logic assumed — a small magnitude like 1e-7 serialized to "0.000000", which the trailing-zero strip then collapsed to "0.0", silently corrupting the value. This path is unreachable from event signing today (every signing caller parses with parse_lossless(), which rejects floats at the parse boundary), but serialize_canonical() itself had no float guard, so any future caller building a Value tree with a double directly would silently produce a wrong, non-canonical hash rather than an error. Split into two entry points: serialize_canonical() keeps accepting floats (now via a portable shortest-round-tripping conversion) because it also serves ordinary, never-signed JSON responses that legitimately contain floats (e.g. m.tag order, account data); serialize_canonical_strict() rejects a Value tree containing any double with the new CanonicalJsonError::float_not_allowed instead of serializing it, and is now what event_signer.cpp, event_id.cpp, and signable.cpp call for signing/hashing payloads.

  • fix(crypto): wrap the operator master key and its derived keys in zeroising, mlocked buffers; consolidate the duplicated master-key loader. load_master_key_material() read the master key file into a plain std::vector<std::uint8_t> and an unwiped stack read buffer — neither zeroised nor mlocked — even though this file is the root secret every derived key (secret-box, access-token HMAC v3/v4, IPC auth) comes from. It now reads into a core::SecretBuffer and returns one. SecretBoxKey, TokenHmacKey, and IpcAuthKey previously wrapped their 32-byte key material in a bare std::array with no destructor; all three now zeroise bytes with sodium_memzero on destruction and on the losing side of every copy/move, while remaining ordinary copyable/movable value types (unlike SecretBuffer, which is heap-based and move-only — a mismatch for these small, pervasively-by-value-passed keys). src/homeserver/room_service.cpp duplicated load_master_key_material instead of calling crypto::load_master_key_material; the duplicate is removed and both call sites in room_service.cpp now delegate to the single src/crypto/ implementation.

Testing

  • test(observability): assert a bare token query parameter is redacted in sanitized_http_target. test_observability.cpp.
  • test(auth): assert /refresh is excluded from client_auth_endpoint_requires_access_token and from the ClientAuthRoute.requires_access_token route table. test_auth_session.cpp, test_auth_client_server_api.cpp.
  • test(homeserver): add a Linux-only integration scenario that connects a real client, holds the accepted connection open with a deliberately incomplete request head, locates the server's accepted socket via /proc/self/fd, and asserts FD_CLOEXEC is set — verified to fail against the pre-fix ::accept() and pass against accept4(..., SOCK_CLOEXEC). test_http_server_listener_flow.cpp.
  • test(canonicaljson): add conformance coverage that serialize_canonical_strict rejects a double at every nesting depth (bare, in an array, in an object) while serialize_canonical still accepts one; add a round-trip regression test for the 1e-7 → "0.0" corruption case. test_canonicaljson_serializer.cpp.
  • test(crypto): add unit coverage for load_master_key_material — exact-byte round-trip including embedded NUL, fail-closed on empty/missing/oversized/exactly-at-cap files, and an end-to-end check that the returned buffer derives working keys through every downstream KDF (derive_secret_box_key, derive_token_hmac_key[_v3], derive_ipc_auth_key). test_crypto.cpp.

Changed

  • chore(release): bump version to 0.10.56 across meson.build, src/main.cpp, src/db_migrate.cpp, packaging metadata, build scripts, and CHANGELOG.md.

0.10.55

Changed

  • docs: consolidate every security-relevant rule scattered across ~25 module AGENTS.md files into a single, explained reference. Rules like "all sockets must be opened with O_CLOEXEC/SOCK_CLOEXEC" (src/net/AGENTS.md) live in terse, per-directory form across src/auth/, src/canonicaljson/, src/config/, src/core/, src/crypto/, src/database/, src/events/, src/federation/, src/homeserver/, src/http/, src/media/, src/net/, src/observability/, src/platform/, src/rooms/, src/sync/, src/trust_safety/, migrations/, packaging/, scripts/, security/, and every tests/*/AGENTS.md — useful as fast-reference instructions while working in that directory, but with no single place to read the reasoning behind a rule end to end, and no home in docs/ where it's visible without already knowing which AGENTS.md file to look in. Added docs/security-coding-rules.md: every rule above, grouped by theme (memory safety, secrets/logging, cryptography, auth/authz, federation, HTTP/network, database, media, platform hardening, sync, trust & safety, packaging, testing requirements, process), each with a why (the concrete vulnerability class or failure mode it closes, citing the CWE where one applies and the specific fixed bug where the rule traces to one logged in docs/threat-model.md) and a source pointer back to the owning AGENTS.md file, plus an index-by-source-file for the reverse lookup. security/coding-rules.md (previously the closest thing to an authoritative security rule list — terse, CWE-tagged, but missing most of the module-specific rules and not discoverable from docs/) is now a pointer to the new document; security/AGENTS.md, the root AGENTS.md key-docs list, docs/AGENTS.md's update-trigger table, docs/coding-rules.md, and README.md all updated to reference it. Every module AGENTS.md file is left untouched — this is purely additive.

0.10.54

Fixed

  • fix(events): reject a self-leave (membership: "leave", sender == state_key) unless the sender's current membership is invite, join, or knock. authorize_event_against_auth_events allowed self-leave unconditionally, with a comment noting "unless banned in some room versions" that was never implemented. Per the room v10–v12 authorization rules ("If the sender matches state_key, allow if and only if that user's current membership state is invite, join, or knock"), a banned user could send a self-leave event to flip their own membership from ban to leave, then knock or rejoin under normal join rules — a ban-evasion authorization bypass. authorization.cpp now checks target_current_membership before allowing a self-leave.

  • fix(events): reject m.room.member events with an unrecognized membership value instead of silently treating them as leave. parse_membership_state fell through to MembershipState::leave for any string outside join/invite/leave/ban/knock, contrary to the spec's "Otherwise, the membership is unknown. Reject." Combined with the self-leave bug above, a malformed membership value (e.g. from a buggy or adversarial remote server) could be admitted into room state under the guise of a "leave". parse_membership_state now returns std::optional<MembershipState>; the top-level requested membership on a new event is rejected when unrecognized, while internal lookups of already-accepted prior state fall back to leave (the safe "not a member" default).

  • fix(federation): reject inbound PDUs verified against a signing key that has passed its valid_until_ts. remote_key_cache.cpp's resolver falls back to a stale cached key (cache.stale_fallback) when a live refresh fails, by design, so callers can distinguish "known but currently unreachable" from "never seen" — but authorize_federation_pdu never checked the returned key's expiry before using it to verify a PDU's Ed25519 signature. If a remote server's key was rotated after a compromise and the old server became unreachable, an attacker holding the old private key could keep forging PDU signatures indefinitely. authorize_federation_pdu gained a now_ts parameter; when non-zero, a PDU verified against an expired key is now rejected with "sender domain signing key has expired". (The equivalent check already existed for X-Matrix request auth in verify_signed_federation_request — this closes the same gap for PDU signature verification.)

  • fix(media): sniff actual upload content instead of copying the client-declared Content-Type into both the "declared" and "sniffed" MIME fields. client_server.cpp built the internal declared_mime|sniffed_mime|scanner_clean|bytes pipe body by duplicating the Content-Type header into both fields, which made evaluate_media_upload's declared-vs-sniffed mismatch check permanently a no-op (it always compared a value to itself). An attacker could upload arbitrary content (e.g. HTML with an embedded <script>) while declaring an allow-listed Content-Type such as image/png, and the quarantine control meant to catch exactly that never fired. Added media::sniff_mime_type() (magic-byte detection for PNG/JPEG/GIF/PDF, a printable-ASCII heuristic for text/plain, falling back to application/octet-stream); client_server.cpp now sniffs the real request body for local uploads, and repository.cpp's fetch_remote_media sniffs the real response body for federated media fetches (a remote server's declared Content-Type is equally untrustworthy).

  • fix(media): reject a Content-Type header containing | on media upload instead of splicing it unescaped into the internal pipe format. The internal declared_mime|sniffed_mime|scanner_clean|bytes protocol relies on | as a field delimiter; http::header_value_is_valid() permits | in header values, so a client-controlled Content-Type containing | could shift the parsed field boundaries and forge the scanner_clean flag and leading body bytes seen by local_http_router.cpp's split_pipe_4. client_server.cpp now rejects such uploads with 400 M_BAD_REQUEST before constructing the pipe body.

Testing

  • test(events): add conformance coverage for self-leave from ban and from no prior membership, and for an unrecognized membership value — all now rejected. test_event_auth_rules.cpp; also updated test_state_resolution_conformance.cpp's conflicting-membership fixtures, which had unintentionally relied on the self-leave bug (their m.room.member events carried no membership field at all) to reach "exactly one winner" — fixtures now use real join/leave content plus a shared public join_rules event so both conflict candidates are independently auth-valid.
  • test(federation): add unit coverage asserting a PDU verified against an expired signing key is rejected when now_ts is provided, accepted when the key is still valid, and accepted via the pre-existing 3-arg overload (now_ts omitted, expiry check skipped). test_federation_inbound_request.cpp.
  • test(media): add unit coverage for sniff_mime_type() (each recognized signature, the text/plain heuristic, empty input, non-printable fallback, and a disguised-HTML payload) and for evaluate_media_upload quarantining a declared/sniffed mismatch. test_media_security.cpp. Added a unit scenario asserting a Content-Type containing | is rejected with 400 rather than reinterpreted. test_client_server.cpp. Fixed pre-existing fixtures across test_media_repository.cpp and test_client_server_conformance.cpp that used placeholder ASCII bodies (e.g. "png-bytes", "test-image-data") declared as image/png, which the now-functional sniffer correctly flags as a mismatch — replaced with real PNG magic bytes.

Changed

  • chore(release): bump version to 0.10.54 across meson.build, src/main.cpp, src/db_migrate.cpp, packaging metadata, build scripts, and CHANGELOG.md.

0.10.53

Fixed

  • fix(sync): Simplified Sliding Sync timeline and required_state events now carry event_id instead of the raw stored PDU JSON. build_room_response pushed a room's stored event JSON (ev.json) straight into the response for both the timeline and required_state arrays. Stored event JSON is the signed wire PDU format, which never carries event_id — it is derived from a reference hash and only ever injected for client-facing responses (see client_server.cpp's client_event_value(), used correctly by /sync and /messages). Sliding sync skipped that conversion entirely, so every timeline and required_state event was missing event_id and additionally leaked federation-only PDU fields (auth_events, prev_events, hashes, signatures, depth) that must never reach a client. Per the Matrix spec's Room Event Format, event_id is mandatory on every client-facing event; ruma-events (the crate matrix-rust-sdk uses to parse each sliding sync timeline entry) rejects an event missing it with missing field 'event_id'. Because this failure happens per-event inside the client's timeline diff — not at the top level of the sliding sync response — the outer response still parsed and committed normally, pos kept advancing, and the connection never looked broken in server logs: the client silently dropped every single message while everything else (typing, receipts, room-list counts) kept working. Reported as "no messages are arriving on the mobile client", initially misdiagnosed as media-specific because a manual server-side repro of a plaintext send/ingest path had not yet exercised the exact Element X connection shape. Root-caused by reproducing that exact shape, capturing the real response body, and feeding it through the actual ruma-events deserializer: confirmed missing field 'event_id' before the fix and a clean parse after. build_room_response now converts stored events through a new client_event_json() helper (mirrors client_event_value()) for both timeline and required_state entries.

Testing

  • test(sync): assert sliding sync timeline and required_state events carry event_id matching the real sent/stored event, not the raw PDU shape. Regression coverage in test_sliding_sync_flow.cpp against the exact Element X "room-list" connection request shape.

Changed

  • chore(release): bump version to 0.10.53 across meson.build, src/main.cpp, src/db_migrate.cpp, packaging metadata, build scripts, and CHANGELOG.md.

0.10.52

Fixed

  • fix(sync): reject a Sliding Sync pos ahead of the live stream with 400 M_UNKNOWN_POS instead of ratcheting it — inbound messages were silently never delivered after a server restart. The pos handling deliberately never regressed a token component: the returned pos was max(current watermark, requested pos), on the theory that a lower cursor would make clients retry indefinitely. In reality, when a client presents a pos ahead of the live stream — matrix-rust-sdk persists pos across app and server restarts (share_pos), while the server's in-memory stream watermark is rebuilt on startup and can come up lower than the previous lifetime's — the ratchet made the stale token permanent: the client's since floor sat above the live stream, so every new inbound event landed in the gap and was filtered as already-seen, the long-poll parked, the room was skipped, and the server echoed the inflated token back, forever. Outbound sending kept working (the send path never consults pos), which produced the confusing "can send but never receive" symptom. Production logs showed this live: a client polling with pos event-component 920 while the server's stream sat at 918. Per MSC4186, a pos the server cannot serve is now rejected with 400 M_UNKNOWN_POS and the connection state is dropped; matrix-rust-sdk handles this by expiring the session and starting a fresh initial sync (ErrorKind::UnknownPos → expire_session()), so clients self-heal immediately. The returned pos is now always the live watermarks.

  • fix(database): persist the timeline stream-ordering watermark so it survives restarts (migration 003_event_stream_watermark). This is the root cause behind clients holding a pos "from the future": next_stream_ordering was rebuilt on startup from max(events.stream_ordering) + 1, but membership stream positions consume orderings without a backing event row, so every restart rebuilt the counter lower than the previous lifetime's — putting every client's persisted sliding sync pos/since token ahead of the live stream. All 16 stream-ordering allocation sites now flow through a new homeserver::allocate_stream_ordering() helper that persists the high-water mark to a new event_stream_watermark singleton table (mirroring the existing sync_stream_watermark from migration 002), and hydration takes the maximum of the persisted watermark and the highest persisted event ordering. Combined with the M_UNKNOWN_POS fix above: restarts no longer regress the stream at all, and the rejection path remains as a safety net for tokens that predate this migration.

Testing

  • test(sync): assert a future pos is rejected with 400 M_UNKNOWN_POS and that the follow-up no-pos request receives a full initial snapshot. Replaces the previous "never regresses a client stream position" scenario, which asserted the harmful ratchet behaviour this release removes.
  • test(database): assert the stream-ordering counter and pre-restart sliding sync pos survive a SQLite restart. A regression scenario creates a room (consuming non-event-backed membership stream orderings), records next_stream_ordering and a sliding sync pos, restarts the runtime from the same SQLite file, and asserts the counter did not regress and the old pos is served rather than rejected with M_UNKNOWN_POS. Migration inventory tests updated for schema version 3 (47 tables, 3 migrations).

Changed

  • chore(release): bump version to 0.10.52 across meson.build, src/main.cpp, src/db_migrate.cpp, packaging metadata, build scripts, and CHANGELOG.md.

0.10.51

Fixed

  • fix(sync): wrap the receipts/typing extensions' per-room payload in a type-tagged event instead of sending bare content. m.receipt and m.typing are EphemeralRoom-kind events (ruma's SyncReceiptEvent/SyncTypingEvent) — the wire shape is {"type":"...","content":{...}}. build_receipts/build_typing were serialising only the bare content object (e.g. {"$event_id":{"m.read":{...}}} with no type/content wrapper at all). Verified directly against ruma-client-api's own deserializer: the bare shape fails with missing field 'type'. matrix-rust-sdk treats a failed extension deserialization as a failed sync iteration — the response is accepted with 200 at the transport level, but the client never commits the new pos and immediately retries the same request, producing an unbounded busy-loop storm on any poll that returns a non-empty receipt or typing notification. This was invisible until 0.10.50's rooms:["*"] fix started actually delivering receipts to Element X for the first time — receipts had always been malformed, but the earlier "*" bug meant they were silently never sent, so the malformed shape never reached a client that would choke on it. Root-caused from a real capture: a fresh sliding sync response was generated locally, matched byte-for-byte against the connection shape reported in production logs, and fed through a standalone Rust program depending on ruma-client-api to confirm the exact deserialization failure before and after the fix.

Testing

  • test(sync): assert extensions.receipts/typing wrap content in {"type":...,"content":...}, not bare content. Regression coverage for the deserialization-storm fix; also updated the existing build_extensions unit coverage in test_sliding_sync_surfaces.cpp, which had asserted the old, broken bare-content shape.

Changed

  • chore(release): bump version to 0.10.51 across meson.build, src/main.cpp, src/db_migrate.cpp, packaging metadata, build scripts, and CHANGELOG.md.

0.10.50

Fixed

  • fix(sync): scope the Sliding Sync long-poll wake check to the extensions and lists a connection actually requested. #375/#376/#377/#378 scoped the wake check to rooms the caller has joined, but it still woke a connection for any signal relevant to the user anywhere — a new timeline event in any joined room, or a receipt/typing update in any joined room — regardless of whether that connection had asked for rooms, receipts, or typing at all. Element X runs a dedicated background sliding sync connection with no lists/room_subscriptions that only enables the to_device/e2ee extensions, used to keep encryption keys flowing without paying for room-list computation. Every message, read receipt, or typing notification in any of the user's rooms woke that connection early, and since it had nothing new to report it replied instantly with an empty snapshot at the same pos, which the client immediately re-polled — a busy-loop sync storm on that connection (observed as sliding_sync.dispatch/sliding_sync.response pairs firing every 70-150ms) while the user was simply active in an unrelated room. The wake check now only considers a room timeline event relevant when the connection has at least one list or room subscription, and only considers a device-list-change/to-device/account-data/receipts/typing row relevant when the connection's request enabled that specific extension.

  • fix(sync): treat "rooms":["*"] in the receipts/typing extensions as "all subscribed rooms", not a literal room ID. MSC4186's AllSubscribed sentinel (ruma's ExtensionRoomConfig::AllSubscribed) serializes to the JSON string "*", and matrix-rust-sdk's RoomListService — used by Element X — sends exactly "receipts":{"enabled":true,"rooms":["*"]} on every sliding sync request. effective_rooms() only fell back to "all rooms in the response" when rooms was omitted entirely; a present-but-["*"] array was matched literally against room IDs, which never matches, so read receipts were silently never delivered to Element X via the receipts extension. effective_rooms() now also treats a single "*" entry as no filter.

  • fix(sync): resolve required_state's "$LAZY" and "$ME" sentinel state keys instead of matching them literally. matrix-rust-sdk's DEFAULT_REQUIRED_STATE (used by Element X's RoomListService) always requests ["m.room.member","$LAZY"] and ["m.room.member","$ME"]. The matcher compared state_key by plain string equality, and a real m.room.member event's state_key is always a user ID — never the literal string "$LAZY" or "$ME" — so Element X never received a single m.room.member event from sliding sync, breaking member lists, display names, and heroes resolution. "$ME" now resolves to the requesting user's own ID. "$LAZY" now resolves to the members relevant to the timeline this response returns (senders and membership-change subjects) when the timeline is truncated or this is the room's first appearance on the connection — matching matrix-rust-sdk/Synapse's lazy-loading behaviour — and to the m.room.member wildcard otherwise, so the client's existing membership cache stays valid across continuous incremental syncs. A member relevant for the first time on a connection is delivered even if their own membership event predates the incremental since-floor, tracked via a new per-connection lazy_members_sent set (mirrors the existing rooms_seen commit-on-ack pattern) so it isn't endlessly re-sent afterward.

Testing

  • test(sync): assert that an e2ee/to_device-only Sliding Sync long-poll parks through a typing notification and message it did not request. A regression scenario mirrors Element X's background connection (no lists/subscriptions, only to_device/e2ee enabled); after bob posts a typing notification and sends a message in a room alice has joined, alice's long-poll (can_wait=true) must return needs_wait, not an empty complete response.
  • test(sync): add end-to-end coverage that mirrors matrix-rust-sdk's actual SyncService request shapes. Reproduces Element X's two concurrent sliding sync connections byte-for-byte — the "room-list" connection's exact DEFAULT_REQUIRED_STATE list, timeline_limit, and account_data/receipts(rooms:["*"])/typing extensions, and the "encryption" connection's to_device/e2ee-only, no-lists shape — against a live homeserver instance. Covers: m.room.encryption is delivered via required_state on a private_chat room; alice's own member event ("$ME") and bob's ("$LAZY", the most recent timeline sender) are both delivered; the encryption-only connection parks through a message/typing/receipt it never asked for; the encryption-only connection wakes and delivers device_lists.changed on a key upload; the room-list connection's receipts extension delivers a receipt requested via rooms:["*"].
  • test(sync): unit-test "$ME"/"$LAZY" required_state resolution directly against build_room_response. Covers: "$ME" resolves to only the requester's own member event; "$LAZY" on an initial/truncated-timeline response scopes to just the members whose events are actually in the delivered timeline (not every joined member); a member newly relevant on this connection has their membership delivered even when it predates the since-floor, and is not re-sent once already delivered; "$LAZY" and "$ME" resolve correctly together, matching Element X's real combined request.

Changed

  • chore(release): bump version to 0.10.50 across meson.build, src/main.cpp, src/db_migrate.cpp, packaging metadata, build scripts, and CHANGELOG.md.

0.10.49

Fixed

  • fix(sync): scope the Sliding Sync long-poll wake check to the requesting user's own rooms. The handler's "has anything relevant changed" gate compared the request's since cursor against the homeserver's global event-stream watermark: any event in any room on the server (not just rooms the caller has joined) made the gate treat the wait as satisfied and fall through to building a response immediately, even though rooms{} had nothing new for this connection. On a multi-room/multi-tenant server the global watermark advances constantly, so long-polls effectively never blocked — the client received a storm of near-instant, empty rooms_in_response=0 responses with a pos that kept advancing regardless. Because the server marks a room's state as "seen" by this connection as soon as a response referencing it round-trips, room state (including m.room.name and m.room.encryption) that arrived inside one of these rapid-fire responses could be marked delivered without the client ever reliably applying it, since the server never resends unchanged state. The wake check now only treats a new room event as relevant when it landed in a room the caller has joined, matching the existing scoping already used for receipts and typing.

Testing

  • test(sync): assert that a Sliding Sync long-poll parks rather than returning early for an event in a room the caller has not joined. A regression scenario has two users, each with their own room; after alice completes an initial sync, bob sends a message in his own room and alice re-polls with a non-zero timeout from her prior pos. It asserts the handler returns needs_wait instead of an empty complete response.

Changed

  • chore(release): bump version to 0.10.49 across meson.build, src/main.cpp, src/db_migrate.cpp, packaging metadata, build scripts, and CHANGELOG.md.

0.10.48

Fixed

  • fix(sync): return MSC4186 rooms[roomId].timeline as a plain [Event] array, not a /v3/sync-style object. The room response previously serialised timeline as {events, limited, prev_batch}, which Element X / matrix-rust-sdk does not accept for MSC4186 Simplified Sliding Sync. The malformed response caused the client to reject the returned pos and replay the same request position, so the connection state never committed and rooms stayed invisible. The timeline field is now a direct array of events as required by the proposal.
  • fix(sync): stop computing per-room prev_batch and limited for Sliding Sync. These fields are not part of the MSC4186 room object; removing them also removes the now-unused SlidingSyncRoomResponse::prev_batch and limited members.

Testing

  • test(sync): assert that the v4 sliding sync response returns timeline as a JSON array. A regression scenario creates a room, sends a message, and performs an initial sliding sync via POST /_matrix/client/v4/sync. It verifies that rooms[roomId].timeline is an array containing the message event and that the legacy {events, limited, prev_batch} object shape is absent.

Documentation

  • docs: document the MSC4186 room object fields and the array timeline shape. docs/matrix-v1.18-client-server-api.md now lists each field in rooms[roomId] and explicitly notes that timeline is a plain [Event] array, not the /v3/sync object.

Changed

  • chore(release): bump version to 0.10.48 across meson.build, src/main.cpp, src/db_migrate.cpp, packaging metadata, build scripts, and CHANGELOG.md.

0.10.47

Documentation

  • docs: reference the latest raw MSC4186 proposal and document the stable v4 endpoint shape. docs/matrix-v1.18-client-server-api.md now links the authoritative raw proposal text (https://raw.githubusercontent.com/matrix-org/matrix-spec-proposals/refs/heads/main/proposals/4186-simplified-sliding-sync.md), lists all three routed endpoints (v4, org.matrix.msc4186, org.matrix.simplified_msc3575), and documents body-level pos/timeout and the singular range key.

Changed

  • chore(release): bump version to 0.10.47 across meson.build, src/main.cpp, src/db_migrate.cpp, packaging metadata, build scripts, and CHANGELOG.md.

0.10.46

Documentation

  • docs: fix accuracy errors and gaps found in a full documentation audit. canonical-json.md no longer claims signed-64-bit integer enforcement (actual: JS-safe-integer range) and now documents the parse_json() general-purpose parser. auth-identity.md no longer lists device-list sync, key-count responses, and key-backup retrieval/deletion as deferred — all three are implemented — and a botched-merge formatting defect is fixed. log-filtering.md and user-manual.md audit-event tables now include request.user_locked/request.user_suspended. http-transport.md no longer describes single-mutex runtime serialisation (actual: 256-way room-striped mutex) and now documents the dedicated sync_pool. user-manual.md's hardening self-check table now reflects the real compile-time/runtime probes instead of describing every check as an unimplemented placeholder, and adds the no_new_privs/capability bounding rows. architecture.md now mentions tests/conformance/ in the testing section. todos/production-milestone.md strikes through the token-hashing and Argon2id items, which were already implemented. tests/smoke/AGENTS.md no longer describes a nonexistent --smoke flag or live-listener checks that the smoke suite doesn't perform. security/coding-rules.md entries now cite a CWE or named vulnerability class per security/AGENTS.md's own requirement; the style-only rules it previously mixed in (member prefix, include ordering/quoting) moved to docs/coding-rules.md.

Changed

  • chore(release): bump version to 0.10.46 across meson.build, src/main.cpp, src/db_migrate.cpp, packaging metadata, build scripts, and CHANGELOG.md.

0.10.45

Fixed

  • fix(sync): treat initial Sliding Sync rooms on a fresh connection as full snapshots, not as deltas from a reused pos. Element X sometimes creates a new conn_id and supplies a pos from another connection or device. The server was using that position as a per-room since floor, so initial rooms reported zero unread notifications for events at or before the borrowed position and could appear stale. Room-level deltas now use 0 for initial rooms and only apply the request pos to rooms this connection has already seen.

Testing

  • test(sync): cover fresh Sliding Sync connections that reuse a pos from another conn_id. A regression scenario creates three joined rooms, sends a message in one, obtains a pos on a seed connection, then syncs with a new conn_id reusing that pos. It asserts that all three rooms are returned with initial=true and that the room containing the message reports a non-zero unread notification count.

Changed

  • chore(release): bump version to 0.10.44 across meson.build, src/main.cpp, src/db_migrate.cpp, packaging metadata, build scripts, and CHANGELOG.md.

0.10.43

Fixed

  • fix(sync): preserve an unacknowledged Sliding Sync snapshot for request retries. The server previously committed list windows and room inclusion immediately after writing a response. If Element X cancelled or retried an initial request with the same prior pos, the retry received an empty list and no rooms despite never acknowledging the original response. Connection state now commits only after the client supplies the returned position; retries are rebuilt from the last acknowledged snapshot.
  • fix(sync): never regress a Sliding Sync position after watermark reconstruction. Responses now retain each component of a client position when the server's reconstructed stream watermark is behind it, preventing Element X from rejecting the lower cursor and retrying the same room-list request in a tight loop.

Testing

  • test(sync): cover retries before Sliding Sync position acknowledgement. A regression scenario asserts that retrying a fresh connection with the same pos retains the original SYNC list operation and room payload.
  • test(sync): cover a client position ahead of the reconstructed Sliding Sync watermark. A regression scenario asserts that the returned position never moves backwards.

Changed

  • fix(ci): make NetBSD package installation resilient to mirror outages. NetBSD CI now retries both official binary-package endpoints and checks dependency installation before invoking the build, avoiding a misleading missing-compiler failure when a mirror refuses a transient connection.
  • chore(release): bump version to 0.10.43 across meson.build, src/main.cpp, src/db_migrate.cpp, packaging metadata, build scripts, and CHANGELOG.md.

0.10.41

Testing

  • test(sync): add an opt-in live Sliding Sync long-poll probe for pong.ping.me.uk. The test authenticates only when MEROVINGIAN_LIVE_ACCESS_TOKEN supplies a short-lived token, creates an isolated conn_id, and verifies that a current pos is held for the requested long-poll interval rather than immediately reissued. It skips without network access or a token and never logs credential or response content.

Changed

  • docs(testing): document safe authenticated live client-server probes.
  • debug(sync): add startup-gated Sliding Sync response diagnostics. Starting the server with --debug emits a request-shape summary with safe connection metadata, positions, counts, enabled extension names, and response size. It never logs connection IDs, request bodies, tokens, event content, or encryption material, and cannot be enabled through per-module log configuration.
  • chore(release): bump version to 0.10.41 across meson.build, src/main.cpp, src/db_migrate.cpp, packaging metadata, build scripts, and CHANGELOG.md.

0.10.40

Fixed

  • fix(sync): sliding sync room list now uses the persistent store as the source of truth. compute_room_list previously enumerated joined rooms from the runtime cache (rt.database.rooms) while reading membership and metadata from the persistent store. This caused rooms to disappear from sliding sync when the runtime cache was stale or unhydrated, notably making rooms created on one device invisible to a second device using MSC4186 sliding sync. The room list is now built directly from store.rooms and store.memberships, matching the database of record.

Testing

  • test(sync): unit and integration coverage for cross-device sliding sync visibility. A new unit test proves compute_room_list still reports a room that exists only in the persistent store, and a new integration test verifies that a room created on a desktop device appears in the sliding sync response for the same user on a mobile device.

Changed

  • chore(release): bump version to 0.10.40 across meson.build, src/main.cpp, src/db_migrate.cpp, packaging metadata, build scripts, and CHANGELOG.md.

0.10.39

Fixed

  • fix(ci): actually run GPG signing and rolling-latest publishing after skipped optional jobs. The previous attempt used bare needs.<job>.result == 'success' conditions, but GitHub Actions still implicitly applies a default success() check to any job-level if that does not contain a status function. Because optional jobs such as netbsd-pkg-retry are skipped, that default success() returned false and kept skipping sign-package-assets, publish-latest, sign-release-assets, and publish-alpha-release. All four jobs now use !failure() && !cancelled() together with the explicit result check so they run whenever their direct dependency succeeds, regardless of skipped upstream jobs.

Changed

  • chore(release): bump version to 0.10.39 across meson.build, src/main.cpp, src/db_migrate.cpp, packaging metadata, build scripts, and CHANGELOG.md.

0.10.38

Added

  • feat(rate-limit): production-grade client-server rate limiting. Per-IP and per-user wall-clock token-bucket tiers are enforced before dispatch. Per-user buckets are keyed by the authenticated user_id; per-IP buckets use the effective client IP plus a normalized route. Path parameters (roomId, deviceId, mediaId, etc.) are coalesced into placeholders so a single cap covers all rooms/devices/media. Exceeded caps return 429 M_LIMIT_EXCEEDED with a Retry-After header (and the deprecated retry_after_ms body field). Operators configure overrides via client_rate_limits.per_ip.<target>, client_rate_limits.per_user.<target>, and client_rate_limits.default_per_ip.
  • feat(voip): static TURN credentials for GET /_matrix/client/v3/voip/turnServer. New server.turn.server, server.turn.username, server.turn.password, and server.turn.ttl_seconds config keys supply static credentials to authenticated VoIP clients. When no TURN server is configured the endpoint returns an empty JSON object so clients gracefully disable relay support.
  • feat(federation): inbound GET /_matrix/federation/v1/media/download/{mediaId} endpoint. Remote homeservers can download locally-uploaded media using X-Matrix-authenticated requests. The route bypasses the federation worker (which has no access to the local media repository) and returns a multipart/mixed response per Matrix v1.18.

  • feat(supply-chain): immutable dependency pinning for catch2 and yyjson. Both wraps are now [wrap-file] entries pointing at release tarballs with SHA-256 source_hash values. scripts/verify-wrap-pins.sh and an updated tests/tooling/test_dependency_wraps.py enforce that every committed wrap is a file wrap with a 64-character hash.

  • feat(license): release-attached license review. docs/dependencies/licenses.md records the license and GPL-3.0-or-later compatibility of every direct and transitive image-codec dependency. The dependency-review-action license check is enabled, scripts/generate-license-summary.py emits a machine-readable merovingian.licenses.json, and the JSON is attached to alpha and rolling-latest releases.
  • feat(release): GPG-signed release artifacts. A reusable .github/workflows/sign-artifacts.yml workflow imports an offline maintainer GPG key and produces detached .asc signatures for every tarball (alpha releases) and every package plus SHA256SUMS (rolling latest releases). Verification instructions are documented in docs/release-process.md and the release checklist.
  • feat(reproducible-builds): verify static Linux tarball reproducibility. scripts/reproducible-build.sh builds the static Linux fallback tarball twice with the same SOURCE_DATE_EPOCH and compares SHA-256 hashes. scripts/build-static-linux.sh now defaults to release builds with --strip, sets SOURCE_DATE_EPOCH from the commit author date, and uses deterministic GNU tar options when available. A dedicated CI job runs the verification on every push to main and on PRs touching the build.

Changed

  • docs(http-transport, user-manual, capability-gaps, build-warning-policy, release-process, security-review-checklist, dependencies): document the implemented rate limiter, TURN configuration, inbound federation media serving, dependency pinning policy, license review, GPG signatures, and reproducible build verification; update config/merovingian.conf.example.
  • chore(release): bump version to 0.10.38 across meson.build, src/main.cpp, src/db_migrate.cpp, packaging metadata, build scripts, README.md, and docs/user-manual.md.

Fixed

  • fix(build): avoid _FORTIFY_SOURCE macro redefinition on musl/Alpine builds. Hardening flags now emit -U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=3 so the project's level-3 fortification overrides any toolchain-default level-2 definition without triggering -Werror.
  • fix(config): keep public registration disabled in config/merovingian.conf.example. The example config now defaults to security.registration.enabled=false, so --check-config and --dry-run smoke tests that copy the example file no longer require a real /etc/merovingian/registration-token.
  • fix(ci): make scripts/reproducible-build.sh work when actions/checkout does not materialize a .git directory. The script no longer relies on git rev-parse --show-toplevel; it derives SOURCE_DATE_EPOCH from git when available and falls back to 0 otherwise.
  • fix(ci): ensure GPG signing and rolling-latest publishing run after package staging. Added explicit needs.<job>.result == 'success' conditions to sign-package-assets, publish-latest, sign-release-assets, and publish-alpha-release so the default success() graph (which can be false when optional retry jobs are skipped) no longer skips the signing and publishing jobs.

Testing

  • test(rate-limit): new and updated unit tests cover route normalization, per-user keying by user_id, Retry-After semantics, and config validation.
  • test(federation, media): route coverage, federation-worker bypass, multipart/mixed envelope construction, and end-to-end handler tests cover the inbound federation media download endpoint.
  • test(tooling): tests/tooling/test_dependency_wraps.py now asserts [wrap-file] pinning with SHA-256 hashes, the absence of [wrap-git] entries, per-dependency review documents with license notes, and a valid machine-readable license summary JSON. tests/tooling/test_packages_workflow.py confirms the version bump is consistent across packaging metadata.
  • test(release): scripts/check-release-readiness.sh now gates the presence of the license review document, the wrap-pin verification script, and the GPG signing workflow references in release.yml and packages.yml.

0.10.36

Added

  • feat(config): explicit listeners.*.reverse_proxy declaration for listener security. Public (non-loopback) client and federation listeners must now use TLS with reverse_proxy=false. Loopback cleartext listeners are only permitted when reverse_proxy=true is explicitly declared, matching the reverse-proxy-first deployment model. Configuration validation rejects ambiguous or unsafe combinations before startup.

Changed

  • docs(user-manual, http-transport, threat-model): document the explicit reverse-proxy listener model and public TLS requirement.

0.10.35

Fixed

  • fix(federation-proxy): FederationProxy::handle() now matches GET /_matrix/key/v2/server exactly rather than by substring, so a malformed target such as /_matrix/federation/v1/state/!room:example.com/_matrix/key/v2/server is no longer mis-routed to the local key endpoint.
  • fix(federation-worker): WorkerEventLoop request handlers now check pool.submit() return value and immediately send a 503 M_UNAVAILABLE IPC response if the pool is stopping, instead of silently dropping the request and letting the main process time out.
  • fix(ipc): deserialize_outbound_http_response() now preserves the specific http::OutboundError code (timeout, connection_failed, invalid_url, etc.) instead of always collapsing failures to network_error.
  • fix(ipc): IpcChannel::dispatcher_loop() now catches unhandled exceptions thrown by request handlers, marks the channel unhealthy, wakes every pending send_request waiter, and exits cleanly instead of calling std::terminate.
  • fix(federation): make_outbound_make_membership() no longer appends ver= query parameters to make_leave per Matrix v1.18 (only make_join and make_knock carry supported-room-version hints).
  • fix(federation-worker): WorkerEventLoop::run() drains local_pool and relay_pool before calling channel->stop(), so in-flight tasks can still send their IPC responses before the fd is closed.
  • fix(federation-worker): WorkerPool::handle() now computes the IPC timeout as max(federation.worker.request_timeout_seconds, security.federation.remote_timeout) + 10 s, so a worker-side outbound HTTP call that legitimately runs longer than the worker request timeout can complete before main declares an IPC timeout.
  • fix(federation-worker): WorkerSupervisor::stop() now marks the supervisor unhealthy and uses an atomic worker_pid_ so TSan-clean health checks and shutdown are observable from any thread.

Testing

  • test(federation-worker): tests/integration/test_federation_worker_flow.cpp now covers WorkerSupervisor bounded stop, unexpected-exit restart/backoff, and SIGKILL escalation when the worker ignores shutdown; tests/unit/test_worker_supervisor.cpp covers the pre-start worker_pid() getter.
  • test(federation-worker): tests/unit/test_worker_event_loop.cpp now covers shutdown sequencing and pool-submit-failure response paths.
  • test(federation-worker): tests/integration/test_federation_worker_flow.cpp exposes FederationProxy::healthy() and replaces the hard-coded 2-second sleep before the outbound proxy test with a wait_for_healthy(proxy) helper that polls pool health.
  • test(federation): tests/unit/test_federation_membership_endpoints.cpp pins that make_leave targets carry no ver= query parameters while make_join still advertises supported versions.
  • test(ipc): tests/unit/test_ipc_framing.cpp pins that an unhandled exception in a request handler does not call std::terminate and instead makes send_request return nullopt.
  • test(ipc): tests/unit/test_ipc_federation_frames.cpp pins that OutboundError round-trips through serialize_outbound_http_response / deserialize_outbound_http_response.

Changed

  • **chore(release): bump version to 0.10.35 across meson.build, src/main.cpp, src/db_migrate.cpp, packaging metadata, build scripts, README.md, and docs/user-manual.md.

0.10.34

Fixed

  • fix(media): federated attachments could be sent but never received. fetch_remote_media_live() called only the deprecated, unauthenticated GET /_matrix/media/v3/download/{serverName}/{mediaId} endpoint, which current Synapse and Merovingian deployments disable by default per the Matrix v1.11 spec change — every remote media fetch 404'd. The fetch path now tries the mandatory authenticated GET /_matrix/federation/v1/media/download/{mediaId} endpoint first (signed with an X-Matrix Authorization header, same as outbound transactions), decodes its multipart/mixed response (parse_federation_media_multipart()), and falls back to the deprecated endpoint with allow_remote=false only on a 404, per spec. A Location-redirect response from the authenticated endpoint is not yet followed (see docs/todos/capability-gaps.md) and falls back the same way.
  • fix(media): serving locally-uploaded media to other homeservers over federation is still unimplemented — flagged as a follow-up capability gap in docs/todos/capability-gaps.md (this server has no inbound route for either the authenticated or deprecated federation media download endpoint).

Added

  • tests(homeserver): new remote_federation_media_download_url() and parse_federation_media_multipart() pure functions, covered by unit tests (tests/unit/test_homeserver_media_service.cpp) and a new conformance suite (tests/conformance/test_federation_media_conformance.cpp) citing SS API §Content Repository.

0.10.33

Added

  • docs: new docs/user-manual.md — a comprehensive operator manual covering installation (packages and source), initial setup, configuration parameters, database backends, running the server, reverse-proxy pointers, user management, federation policy, media repository, logging/diagnostics, maintenance, troubleshooting, and a security checklist.
  • docs(readme): README.md now links to docs/user-manual.md and reports the current beta version (v0.10.33).
  • docs(user-manual): the Reverse proxy section now includes complete, copy-paste configs for nginx, Apache httpd, Caddy, Traefik, HAProxy, and Cloudflare (TLS termination, .well-known discovery, client/media/federation/key routing, X-Forwarded-For rate-limit hardening) plus the per-proxy CORS smoke test, folded in from the retired docs/configuration.md.

Changed

  • chore(release): bump version to 0.10.33 across meson.build, src/main.cpp, src/db_migrate.cpp, packaging metadata, and build scripts.
  • docs: docs/configuration.md is retired — every section (fail-closed startup, listener/TLS policy, secrets at rest, registration/token lifetimes, federation inbound/outbound/join/worker controls, size and duration formats, trust-safety transport, reloadability policy, runtime config snapshot, startup hardening self-check, production packaging, CORS, client rate limits, log-module overrides) is now part of docs/user-manual.md's Configuration, Federation, and Media repository sections. AGENTS.md, README.md, docs/getting-started.md, docs/hardening.md, docs/http-transport.md, docs/media-repository.md, docs/release-process.md, src/config/AGENTS.md, packaging spec/PLIST files, scripts/build-static-linux.sh, and two source comments now point at docs/user-manual.md instead.

0.10.32

Added

  • feat(federation): inbound /send now applies authenticated per-origin abuse controls: Matrix v1.18 transaction caps are enforced explicitly (50 PDUs, 100 EDUs by default), and each verified remote origin has configurable transaction, PDU, and EDU rate buckets so a burst from one server is throttled by actual federation pressure rather than by spoofable event sender IDs.
  • feat(config): operators can tune federation abuse limits with security.federation.max_transaction_pdus, security.federation.max_transaction_edus, security.federation.per_origin_transaction_rate, security.federation.per_origin_pdu_rate, and security.federation.per_origin_edu_rate.
  • docs(federation): configuration and architecture docs now explicitly distinguish inbound /send abuse controls from outbound destination queue/backoff controls.

Fixed

  • fix(federation-worker): inbound federation dispatch no longer holds HomeserverRuntime::mutex across whole /send transactions: the previous wrapper lock survived the earlier PDU-stripe and IPC handler-pool fixes, so a worker shard still serialized relayed transactions while each request waited on worker-to-main sink IPC. handle_federation_http_request() now holds the global runtime mutex only for startup checks, callback wiring, key publication, request identity construction, and trust-safety policy lookup, then releases it before handle_inbound_federation_request() runs.
  • fix(federation): FederationRuntimeState now protects its own mutable bookkeeping while federation requests run concurrently: remote records are copied out for request processing, remote signing-key/trust updates are persisted through narrow guarded helpers, and accepted transaction/audit vectors are updated under a federation-local mutex instead of relying on the homeserver global mutex.
  • fix(federation): valid but excessive /send traffic now returns 429 M_LIMIT_EXCEEDED at the origin policy boundary while individual invalid PDUs still report per-PDU errors in a 200 transaction response, preserving Matrix federation retry semantics.
  • fix(federation): handle_inbound_federation_request() no longer passes a const remote record to check_inbound_request_signature() and no longer dereferences a non-optional remote when choosing the PDU signing key, restoring compilation after the burst-delivery refactor.
  • fix(federation): federation_summary() now formats per-origin transaction/PDU/EDU rates as N/Ws (e.g. 120/60s) so the runtime summary matches the policy syntax and the bounded-operational-values test passes.
  • fix(fuzz): fuzz-config-parser now links http_lib, observability_lib, core_lib, and platform_lib because config validation calls http::rate_limit_policy_is_valid, and rate_limit.cpp pulls in observability symbols.

Testing

  • test(federation): tests/unit/test_federation_runtime_callbacks.cpp now blocks a federation transaction sink and asserts unrelated runtime work can still acquire HomeserverRuntime::mutex, preventing the wide-lock drip-feed regression from returning.
  • test(federation): transaction validation and inbound federation tests now cover Matrix PDU/EDU count caps plus per-origin transaction/PDU/EDU throttling without incrementing remote trust failures.

0.10.31

Fixed

  • fix(federation): inbound PDU ingestion no longer serializes all federation traffic behind a single runtime.mutex — per-room striped locks now protect room-local ordering while independent rooms commit concurrently: the default pdu_sink previously held the global runtime mutex for the entire auth, persistence, membership-update, and sync-notification path. Under a burst of 30–40 inbound federation events this forced every PDU to wait for the previous one's database commit, turning quick succession into a drip-feed even though SQLite/PostgreSQL backends open a fresh connection per transaction and could otherwise overlap. Fix: HomeserverRuntime now owns 256 room-stripe mutexes; ingest_pdu_event() reserves a global stream-ordering token, then acquires only the stripe for the event's room_id while building the auth map, preparing the prepared-state update, applying in-memory rows, and updating membership, releasing that stripe for the actual database commit so commits for different rooms run in parallel. database::store_event_with_state() is refactored into prepare_store_event_with_state(), commit_persistent_transaction(), and apply_store_event_with_state() to make the lock-free commit boundary explicit.
  • fix(federation): ingest_pdu_event() now allocates a fresh sync_stream_id for every accepted PDU instead of re-reading persistent_store.next_sync_stream_id: the previous read published the same counter value repeatedly (usually 0 for a fresh store), breaking sync-token advancement for federation-driven events and any test that checked the published sync surface advanced.
  • fix(federation): ingest_pdu_event() no longer performs the sync_stream_id backend write while holding both a room stripe and the global runtime.mutex: both IDs are now reserved under the global mutex alone before the stripe is acquired. This removes the long database write from the per-room critical section, letting other events for the same room proceed and avoiding a broad contention window under ThreadSanitizer.
  • fix(ci): ThreadSanitizer CI job now preserves its live output and Meson test log as artifacts: the previous workflow only surfaced what GitHub Actions captured in the run log, which truncated before the timed-out scenario name and omitted build-tsan/meson-logs/testlog.txt. The tsan job now tees stdout/stderr to tsan-ci-log.txt and uploads that file plus the Meson logs with if: always(), so future hangs show exactly which scenario is stuck.
  • fix(federation-worker): WorkerSupervisor::stop() no longer logs false-positive "Federation worker did not exit" warnings when the supervisor thread has already reaped the child: waitpid returning ECHILD is now treated as a successful reap, so the fallback SIGTERM/SIGKILL escalation is skipped instead of being directed at a process that no longer exists.
  • fix(federation-worker): WorkerSupervisor no longer blocks its supervisor thread in a synchronous waitpid while waiting for the child to exit: the previous blocking wait meant stop() could not join the supervisor thread until the worker process exited, so a TSan-instrumented worker that was slow to terminate (or stuck during teardown) delayed the whole WorkerPool::stop() by the full child-exit time. The supervisor loop now polls with WNOHANG and a short sleep, so running_ = false promptly unwinds the loop and stop() proceeds to its own bounded reap/SIGTERM/SIGKILL sequence.
  • fix(federation): ingest_pdu_event() no longer reports membership PDUs as internal_error after the event has already been committed: if upsert_membership() fails after apply_store_event_with_state(), the event is already in the database and the accepted result has been published, so returning an error to the upstream server is incorrect and can cause duplicate retries. The function now logs a warning and keeps the accepted result, matching the fact that the PDU was already accepted.
  • fix(ci,test): tests/integration/test_seccomp_sqlite_flow.cpp no longer hangs CI when a blocked syscall is called in a retry loop under ThreadSanitizer: the seccomp test uses SECCOMP_RET_TRAP so the SIGSYS handler can report missing syscalls, but if SQLite/glibc retried the same blocked syscall (e.g. fchown when the child could not drop to a non-root uid), the handler kept trapping forever and the parent blocked on waitpid. The handler now limits itself to 32 reports and then terminates the child with exit_group, so the test fails fast and names the offending syscall instead of consuming the full 1800s CI timeout.

Testing

  • test(federation): new tests/unit/test_federation_pdu_ingest_concurrency.cpp exercises concurrent ingest_pdu_event() calls across distinct rooms and asserts the resulting events/membership/stream-ordering state is correct.
  • test(federation): tests/unit/test_federation_pdu_ingest_concurrency.cpp no longer calls REQUIRE from worker threads, which caused ThreadSanitizer to report races on Catch2's internal assertion counters; all envelopes and all post-join state checks are now built/verified on the main thread while workers only update an atomic accepted counter.

0.10.30

Fixed

  • fix(federation-worker,ipc): WorkerPool now runs every worker-to-main ingest handler on a dedicated handler thread pool instead of inline on the IpcChannel dispatch thread: the 0.10.29 reader/dispatch split stopped the reader thread from freezing, but the single per-channel dispatch thread still serialized every pdu_ingest, membership_ingest, edu_ingest, invite_ingest, otk_claim_ingest, user_devices_ingest, device_keys_query_ingest, profile_query_ingest, event_query_ingest, and sign_request. A slow pdu_ingest (e.g. persisting an event under runtime.mutex and waking sync notifiers) therefore parked the dispatch thread and delayed every later frame on that channel, including responses to unrelated outbound requests — the residual drip-feed on high-rate federation. Fix: WorkerPool constructs a net::ThreadPool (handler_pool_, sized from federation.worker.relay_threads) and submits each ingest handler to it, so the dispatch thread only classifies and enqueues frames. Slow ingestion now runs concurrently with later frames.
  • fix(federation-worker): WorkerSupervisor::stop() and the supervisor restart path no longer deadlock with the IPC dispatch thread: both paths previously held channel_mu_ while calling channel_->stop(), which joins the dispatch thread; a pdu_ingest handler running on that thread can call back into the same supervisor's channel_snapshot() (via notify_room_changed()) and block on the same mutex. The stop/restart code now takes ownership of the channel under the lock and releases the lock before calling stop(), keeping the channel alive through a std::shared_ptr snapshot so callers can finish safely.
  • fix(ci): ThreadSanitizer integration-test job no longer times out at Meson's default 600 s: TSan-instrumented binaries run slower than ASan, and the seccomp/SQLite fork-heavy integration scenario was being killed by the default timeout, producing a storm of signal-unsafe call inside of a signal warnings from Catch2's fatal signal handler as it tried to print a summary while handling the SIGTERM. The tsan job now sets MESON_TEST_TIMEOUT_MULTIPLIER: 3, matching the existing asan-ubsan job.
  • fix(federation-worker): WorkerPool::stop() now drains handler_pool_ before stopping worker supervisors: without this, in-flight main-side IPC handlers could still be running when their channel was closed, and tests that stopped the pool immediately after startup could hang during TSan-instrumented teardown while a handler thread raced a channel whose dispatch thread had already been joined.
  • fix(federation-worker): WorkerSupervisor::stop() no longer waits indefinitely for the child process: the previous blocking waitpid(worker_pid_, nullptr, 0) could hang forever if a TSan-instrumented worker child deadlocked or failed to exit. The supervisor now polls with WNOHANG, waits up to request_timeout_seconds, then escalates to SIGTERM, waits again, and finally SIGKILL and a reaping wait. This keeps a stuck worker from blocking process shutdown or test teardown.

Testing

  • test(ipc): new IpcChannel scenario in tests/unit/test_ipc_framing.cpp models the main-side handler-pool fix: a deliberately slow request handler is offloaded to a net::ThreadPool while a fast second request is also offloaded, proving the fast request replies before the slow one completes and that the dispatch thread is not serialized by slow work.

0.10.29

Fixed

  • fix(ipc,federation-worker): federated messages no longer stall for ~60 seconds each and drip-feed in over minutes of origin-server retries — IpcChannel now routes frames on the reader thread and runs request handlers on a separate per-channel dispatch thread: the reader thread previously invoked the request handler inline for every inbound request/notification frame, so a handler that blocked on a lock froze all frame routing for the channel — including responses to the channel's own pending send_request calls sitting right behind the blocking frame. Two production deadlock cycles followed from this. (1) Worker side, on every relayed PDU: the worker's relay thread holds the worker runtime.mutex for the whole of handle_federation_http_request() — including its pdu_sink IPC round trip to main — while main's pdu_ingest handler, after committing the event, sends a room_sync notification (whose worker-side handler needs that same worker mutex) immediately before the pdu_ingest response. The worker's reader thread picked up the notification first, blocked inline on runtime.mutex, and could never route the response one frame behind it; the relay thread waited out the full 60s pdu_ingest timeout, main's WorkerPool::handle() 503'd the transaction at request_timeout_seconds, and the origin server re-sent it on an exponential backoff — a flurry of messages between two users then trickled in one ~60s stall at a time even though main had already committed and sync-published each event on first receipt. (2) Main side, the mirror image: a client-server handler holding main's runtime.mutex across a worker-proxied outbound call (e.g. the remote /keys/query fan-out, common in E2EE conversations) waits on an outbound_http_response that main's reader thread cannot route when a concurrent pdu_ingest arrived first and blocked inline on that same mutex — both sides then sat out their full timeouts. Fix: request frames are queued and handled one at a time, in arrival order, by a dedicated dispatch thread per channel (preserving the previous serial-handling semantics), while the reader thread only wakes send_request waiters and enqueues — a blocked handler can no longer stall response delivery in either process. Additionally the worker now runs room_sync reloads on local_pool instead of the channel's dispatch thread, since reload_room needs runtime.mutex, which an in-flight relayed transaction can legitimately hold for its full duration — parking the dispatch thread on it would delay every later queued request frame. See docs/architecture.md, "IPC reader/dispatch split".

Testing

  • test(ipc): two new IpcChannel scenarios in tests/unit/test_ipc_framing.cpp: a regression test that models the drip-feed deadlock directly (a request handler needing a mutex held by a thread blocked in send_request, with the notification frame arriving ahead of the response frame — times out on the pre-fix inline dispatch, passes with the dispatch thread) and an ordering test pinning that request frames are still handled strictly in arrival order with the new dispatch thread.

0.10.28

Fixed

  • fix(federation,e2ee): remote users joining a Merovingian-hosted encrypted room now receive local device-list updates immediately: the send_join membership acceptor now broadcasts m.device_list_update EDUs for existing local members in the room to the joining user's server, so the remote client can query/use current Merovingian device keys before sending its first encrypted reply.
  • fix(federation-worker,e2ee): EDU-only /send transactions no longer queue behind shard-0 worker traffic: after X-Matrix verification in main, transactions with no PDU room ID are handled in main directly instead of being routed to worker shard 0 and relayed back to main's edu_sink, reducing delayed or timed-out m.direct_to_device key-share delivery.

Documentation

  • docs(federation-worker): documented the EDU-only /send main-process bypass and narrowed relay-pool wording to PDU-bearing transactions.

Testing

  • test(federation,e2ee): wire the remote-join device-list fanout unit test's dispatch worker to the persistent store it asserts against and cover the guard that skips login-only devices without uploaded device keys.

0.10.27

Documentation

  • docs(architecture): align the architecture overview with the current implementation and Matrix v1.18 references: refreshed the module list, runtime model, federation-worker consistency model, implemented API summaries, and spec citations in docs/architecture.md.

0.10.26

Fixed

  • fix(federation-worker): a federation worker shard could go fully unresponsive for roughly 30 seconds at a time with no crash or supervisor restart logged, coinciding with a burst of 503s a remote peer typically retried successfully: every merovingian-fed-worker process ran a single fixed-size thread pool (federation.worker.threads, shipped default 4) for two jobs at once — dispatching every incoming fed_request forwarded from main, and, inside that same dispatch, blocking synchronously on an IPC round-trip back to main whenever the request needed pdu_sink, edu_sink, membership_acceptor, invite_handler, or one of the query-provider relays (profile_query_provider, device_keys_query_provider, user_devices_provider, event_query_provider, one_time_keys_claim_provider — see the 0.10.19-0.10.24 relay fixes above). Those calls wait on main across a single std::recursive_mutex runtime.mutex shared by every relayed operation from every shard, with timeouts of 10-60 seconds. If enough of them landed on one shard concurrently to occupy all threads pool workers at once, the pool's unbounded queue had nowhere to run a newly arrived fed_request — even one that would never need to call main at all, such as a state/backfill/state_ids read answered entirely from the worker's own local snapshot. The shard then appeared fully hung until the backlog drained, at which point WorkerPool::handle()'s own federation.worker.request_timeout_seconds (shipped default 30s) elapsed and returned a 503 to the remote peer. The same mechanism plausibly also explains intermittent to-device delivery gaps investigated this cycle (see Testing below): an EDU's own edu_sink call queued behind this backlog can complete so late that nothing is still listening for it. Fix: worker_event_loop.cpp now runs two pools — local_pool (existing threads) for endpoints answerable entirely from the worker's own local snapshot (make_join/make_leave/make_knock, backfill, query/directory, state, state_ids, get_missing_events, hierarchy, and the no-op send_edu stub), and a new relay_pool (federation.worker.relay_threads, default 32) for the ten endpoints that can call main (transaction, send_join/send_leave/send_knock, invite, query_profile, query_keys, claim_keys, query_user_devices, query_event) plus every outbound_http_request message, which blocks on a remote server's response instead. The classification is a new pure function, federation::federation_endpoint_requires_main_relay(FederationEndpoint), applied to the existing route match before a thread is committed. New scenario in tests/unit/test_federation_transactions.cpp iterates every entry in federation_routes() and asserts each lands in its expected pool bucket, so a future endpoint added without an explicit classification is caught immediately (the switch has no default: case). See docs/architecture.md, "Federation worker relay pool separation".
  • fix(ci): the Fedora, RHEL, and OpenSUSE RPM package jobs started failing after the 0.10.25 merge — the .rpm build itself succeeded every time, but the Attest ... RPM step (actions/attest-build-provenance@v2) failed with InternalError: error fetching tlog entry - (404) Not Found: Sigstore's Rekor transparency log had not finished replicating the entry the action itself just wrote by the time it immediately tried to read it back — an external infrastructure race, not a build or packaging defect. Fix: each of the three Attest steps in .github/workflows/packages.yml now retries up to twice with a short delay (15s, then 30s) before giving up for real, using continue-on-error plus steps.<id>.outcome gating — the same repo-native retry pattern already used for the netbsd-pkg-retry job and the zypper install loop in the OpenSUSE job, applied at step granularity since re-running the whole build is unnecessary. The final attempt in each chain has no continue-on-error, so a genuine, persistent failure still fails the job.

Testing

  • test(federation,sync): investigated the ongoing intermittent E2EE key-share decrypt failures reported on pong.ping.me.uk and ruled out three suspects against the real code paths rather than test-only shortcuts: a large, deeply nested Olm-encrypted m.direct_to_device EDU was routed through handle_federation_http_request() (the real production/worker entry point, as opposed to handle_local_http_request()'s federation branch, which looks similar but is a test-only shortcut that never calls wire_federation_callbacks_impl()) in tests/integration/test_federation_inbound_flow.cpp; the same realistic EDU was sent over a real encrypted ipc::IpcChannel pair (real AF_UNIX socketpair, real AEAD framing) in tests/integration/test_federation_worker_flow.cpp, proving a large nested payload survives the actual worker/main transport; and tests/unit/test_sync_handler.cpp wired federation callbacks the way main.cpp does at startup and called handle_edu_ingest_request() (what a worker's IPC relay lands on) while a client was parked in a long-poll, proving the sync notifier wakes exactly as it does for a local to-device send. All three passed, ruling out large-payload parsing, the IPC transport, and sync-notifier wiring as the cause; this investigation is what led to identifying and fixing the federation worker thread-pool starvation bug above.

0.10.25

Diagnostics

  • diag(federation): handle_inbound_federation_request()'s own logging (request.received, transaction.accepted) has never been observed in production despite clear downstream evidence that it executes — event_state.persisted (logged on main's side when a worker relays a PDU via pdu_ingest) fires reliably for real transactions, which is only reachable by that function calling pdu_sink partway through its body, yet neither its first line nor its final log line ever appears, with no exception (thread_pool: event=worker.exception never fires on either process's thread pool), no worker crash/restart, and no seccomp syscall denial (write/writev/futex/clone are all allowed in the worker hardening profile) found to explain it: since handle_inbound_federation_request runs entirely inside the federation worker's own process when a room-scoped request is relayed there, and its logging is the one piece of visibility this investigation could not otherwise obtain, WorkerPool::handle() — which runs on main, whose own logging has been reliable throughout every incident investigated so far — now logs the raw status, body length, and a short body prefix of every reply a worker sends back, immediately after deserializing it and before returning it to the caller. This does not fix a known bug; it exists to determine, from a call site proven trustworthy, whether the worker's reply actually originated from handle_inbound_federation_request's normal success path (in which case the worker's own logging has a real, separate gap worth fixing next) or from somewhere else entirely.

0.10.24

Fixed

  • fix(federation,security): the federation worker's user_devices_provider, device_keys_query_provider, and profile_query_provider decided their answers from a per-process in-memory snapshot hydrated once at worker startup, with no mechanism to ever refresh it, causing a remote server's E2EE key-share to silently fail forever: unlike the write-relay hooks (pdu_sink, membership_acceptor, edu_sink, invite_handler) and the split-brain hook (one_time_keys_claim_provider), these three are pure reads backing GET /_matrix/federation/v1/user/devices/{userId}, POST /_matrix/federation/v1/user/keys/query, and GET /_matrix/federation/v1/query/profile — none of which carry a room ID, so unlike state_query_provider/state_ids_query_provider/backfill_provider/missing_events_query_provider (kept fresh by notify_room_changed()/reload_room(), see "Federation worker room staleness") there is no per-room notification channel that could ever refresh PersistentStore::device_keys/PersistentStore::profiles on a worker. A device key uploaded, or a profile changed, through main's client-server API after a worker started is therefore permanently invisible to that worker. Reproduced against a real two-homeserver federation (matrix.ping.me.uk → pong.ping.me.uk, federation.worker.shards=2, the shipped example config): matrix.ping.me.uk queried GET /_matrix/federation/v1/user/devices/@james:pong.ping.me.uk to learn which device to target an m.room_key to-device share at, got a spurious 404 M_NOT_FOUND from a worker whose device-key snapshot predated the device's key upload, and never sent the room key — the recipient's client logged Can't find the room key to decrypt the event, withheld code: None for every message under that session, with nothing in the server's own logs indicating a failure, since the transaction carrying the encrypted PDU itself was still accepted and delivered normally. Fix: worker_event_loop.cpp now overrides all three providers the same way it overrides one_time_keys_claim_provider — serializes the request (a bare user_id, or the raw federation request body for device_keys_query_provider) into a new user_devices_ingest/device_keys_query_ingest/profile_query_ingest IPC frame and calls main, rather than deciding locally; worker_pool.cpp's handle_user_devices_ingest_request()/handle_device_keys_query_ingest_request()/handle_profile_query_ingest_request() (free functions alongside the other *_ingest_request handlers, same test-seam reason) receive them on main's side and invoke main's own unmodified providers under runtime.mutex. New scenarios in tests/integration/test_federation_worker_flow.cpp seed a device key and a profile on main's own store, drive each handler with a request shaped like a real worker's ingest frame, and assert the response reflects main's data rather than an empty worker-local snapshot. See docs/architecture.md, "Federation worker user/device/profile/event query relay".
  • fix(federation): event_query_provider (GET /_matrix/federation/v1/event/{eventId}) has a related but structurally different bug from the three above — its path carries no room ID either, so unlike state/state_ids/backfill/get_missing_events it can never be sharded by room and always lands on shard 0 regardless of which shard actually owns the event's room: the same class of routing-alignment problem query/directory already has (see "Shard routing must key on the same room ID string as the notification"), not the "no notification channel exists at all" problem the three providers above have — PersistentStore::events for a room hosted on a shard other than 0 is never refreshed on shard 0, since notify_room_changed() hashes and delivers to the room's actual owning shard, not shard 0 unconditionally. A real routing fix would need an event-ID-to-room-ID index reachable before shard selection, which does not exist today. Fix: rather than build that index, event_query_provider is relayed through main via a new event_query_ingest IPC frame the same way the three providers above are — main receives every event via pdu_sink regardless of which shard accepted it, so answering from main's store is correct no matter which shard the request lands on. worker_pool.cpp's handle_event_query_ingest_request() follows the same free-function/test-seam pattern as the other *_ingest_request handlers. New scenario in tests/integration/test_federation_worker_flow.cpp seeds an event on main's own store and asserts the relayed response contains it. See docs/architecture.md, "Federation worker user/device/profile/event query relay".

0.10.23

Fixed

  • fix(media,security): fetch_remote_media_live() fabricated scanner_clean=true/decoder_marked_safe=true for every federated media fetch, bypassing the AV-scanner gate for attacker-controlled content whenever security.media.remote_fetch_enabled is on: the remote-fetch path is the only untrusted-content boundary in the media repository — unlike a local upload, a federated origin server has no accountable local identity behind it — yet it reported the same "clean" verdict as a locally authenticated upload with no real scan ever having occurred (Merovingian does not run an AV engine for any media source today; see docs/media-repository.md). Fix: remote_req.scanner_clean is now honestly reported as false (no scan happened), and a new MediaAcceptancePolicy (allow / allow-after-scan / quarantine / deny) governs the final disposition, configured independently for local uploads (security.media.local_upload_policy, default allow-after-scan, preserving prior behaviour) and remote fetches (security.media.remote_fetch_media_policy, default quarantine, since federated content is never actually scanned). decoder_marked_safe is deliberately left true for remote content: unsafe_decoders_disabled has no config knob today, so flipping it would hard-reject every remote fetch in every deployment by default rather than fail safely — tracked as a follow-up alongside real AV-scanner integration. New scenarios in tests/unit/test_media_security.cpp (evaluate_media_upload's four-policy behaviour) and tests/unit/test_media_repository.cpp (remote fetch quarantined by default vs. local upload accepted by default under identical scanner-clean input).
  • fix(media,security): the outbound federation media download URL omitted the {serverName} path segment the spec requires and never percent-encoded either segment: fetch_remote_media_live() built /_matrix/media/v3/download/{mediaId} instead of /_matrix/media/v3/download/{serverName}/{mediaId} (server-server-api.md#get_matrixmediav3downloadservernamemediaid), so a media ID or origin server name containing reserved characters could be misread as an extra path segment or a different route on the resolved host. Fix: the URL construction is extracted into remote_media_download_url() (homeserver/media_service.hpp), which builds the correct two-segment path with both origin_server and media_id passed through core::percent_encode_path_component(). New tests/unit/test_homeserver_media_service.cpp pins the URL shape and the encoding of reserved characters.
  • fix(http,security): trusted-proxy X-Forwarded-For rate-limit keying trusted any non-empty value verbatim with no IP validation: when the direct peer was a configured server.trusted_proxies entry, the client-server rate limiter used the leftmost X-Forwarded-For value as the rate-limit key without checking it was a syntactically valid IP address, letting an attacker (or a misconfigured proxy that fails to overwrite an inbound header) rotate through malformed pseudo-IP strings to mint a fresh bucket per request and defeat per-IP rate limiting on /login, /register, and every other endpoint. Fix: the candidate is now validated with the new federation::ip_address_is_valid() (strict inet_pton-based IPv4/IPv6 literal check) before being trusted; a missing or malformed value falls back to the direct peer address. New tests/unit/test_client_server.cpp scenario asserts two malformed X-Forwarded-For values from the same trusted proxy share the direct-peer bucket instead of minting independent ones.
  • fix(media,security): admin media quarantine/release/remove routes accepted unsanitized media IDs from the raw path suffix: unlike the download/thumbnail routes (local_media_download_parts()), the /_merovingian/admin/media/{quarantine,release,remove} routes passed the raw path suffix directly as the media ID with no validation, so a request like .../remove/<id>?reason=x treated the query string as part of the ID — no record matched it, and the intended object was silently left untouched instead of acted on. Fix: admin_media_id_from_suffix() (local_http_router.cpp) strips any query string and rejects an empty ID, an embedded /, a .. traversal sequence, or an embedded space before the ID reaches the admin action; a 400 is returned instead. New tests/integration/test_media_repository_security.cpp scenario covers a trailing query string (correctly stripped, object still acted on), a path-traversal sequence, and an empty ID.

Documentation

  • docs(media,security): made explicit, in the places an operator is most likely to read them, that AV scanning can never inspect encrypted-room media: the new MediaAcceptancePolicy keys risked implying a scanner verdict is meaningful for all media, when in fact application/octet-stream (E2EE attachments encrypted client-side before upload) is opaque ciphertext the server never holds the key for — no configuration, proxy placement, or future scanner integration can change that without breaking end-to-end encryption's confidentiality guarantee. Added a WARNING block to config/merovingian.conf.example's media section, a new "Encrypted media is never scannable" section to docs/media-repository.md, a leading paragraph to the media section of docs/configuration.md, and a cross-reference from docs/architecture.md's media-security summary line.

0.10.22

Testing

  • test(federation): the 0.10.19-0.10.21 worker/main relay fixes (membership_ingest, edu_ingest, invite_ingest, otk_claim_ingest) only had happy-path integration coverage — every existing scenario asserted accepted:true/status:200, none exercised the fail-closed branches those handlers actually contain: added eight new scenarios to tests/integration/test_federation_worker_flow.cpp, driving each handle_*_ingest_request() free function directly with the same wire format worker_event_loop.cpp produces (the same test-seam pattern the original scenarios established). handle_membership_ingest_request: relaying a send_join for a room main has never stored now asserts the existing {false, 404, "room not found"} branch and that no membership row is written; calling it with membership_acceptor unwired asserts the 501 fallback rather than assuming it can't happen. handle_edu_ingest_request: a m.direct_to_device EDU missing the required messages field now asserts rejected_invalid and that nothing reaches to_device_messages; an unwired edu_sink asserts rejected_invalid with a "edu_sink not wired" reason instead of crashing on an empty std::function. handle_invite_ingest_request: inviting a user this server does not host now asserts the 404 "invited local user not found" branch and that no membership/invite row is written; an unwired invite_handler asserts 501. handle_otk_claim_ingest_request: the actual split-brain property 0.10.21 fixes is pinned directly — two consecutive claims for the same (user, device, algorithm) now assert the second returns no key material, since the first claim must consume it from main's single store; a device with no stored one-time or fallback key asserts an empty one_time_keys object rather than an error. A shared ipc_escape_json_string() helper replaces the escaping lambda duplicated across scenarios. No production code changed; all new and pre-existing scenarios in the file pass (94 assertions, 12 test cases for [membership],[edu],[invite],[otk] alone).

0.10.21

Fixed

  • fix(federation): the federation worker's invite_handler persisted a federated invite's membership row, invite metadata, and event only into whichever process ran it — never relayed to main, the same class of gap 0.10.19 fixed for membership_acceptor: invite_handler was not among the hooks the worker overrides (pdu_sink, membership_acceptor, edu_sink were), so PUT /_matrix/federation/{v1,v2}/invite — room-scoped, so handled by a worker whenever federation.worker.shards >= 1 (the shipped example config) — ran the default implementation (local_http_router.cpp) unmodified, writing upsert_membership/database::upsert_invite/database::store_event_with_state straight into that worker's own PersistentStore. Since real clients only ever /sync against main, and nothing else ever re-syncs invite data into main's store, a remote server inviting one of this server's local users to a room hosted elsewhere was silently swallowed: the invite never appeared in the invited user's own /sync, with no error anywhere. Fix: worker_event_loop.cpp now overrides invite_handler the same way it overrides membership_acceptor — serializes the InviteRequest into a new invite_ingest IPC frame and calls main instead of persisting locally; worker_pool.cpp's handle_invite_ingest_request() (a free function alongside handle_membership_ingest_request(), same test-seam reason) receives it on main's side and invokes main's own unmodified invite_handler under runtime.mutex. New integration scenario in tests/integration/test_federation_worker_flow.cpp drives handle_invite_ingest_request() directly and asserts the invite's membership row and invite metadata land in main's own PersistentStore. See docs/architecture.md, "Federation worker invite relay".
  • fix(federation,security): the federation worker's one_time_keys_claim_provider decided one-time-key availability from a per-process in-memory snapshot, risking a key being handed out twice (breaking Olm's single-use guarantee) and going permanently stale as fresh keys were uploaded through main: database::claim_one_time_key searches PersistentStore::one_time_keys — an in-memory vector hydrated once at worker startup — before issuing a DELETE; that vector is never invalidated by a different process's write to the same underlying table. POST /user/keys/claim is non-room-scoped and lands on shard 0 in steady state, but WorkerPool::handle() falls back to running in-process on main when the owning shard is unhealthy — if shard 0 already claimed a key and main's own copy hasn't independently learned it's gone, a later claim falling back to main can return the same one-time prekey a second time, undermining the forward-secrecy guarantee a one-time key exists to provide. The same divergence also caused key claims to dry up in the other direction: keys uploaded through main after a worker started were invisible to that worker's frozen snapshot. Fix: rather than patch the claim-and-delete to be atomic at the database layer (which would still leave main's own cache diverged from a worker's writes), one_time_keys_claim_provider is now relayed through main entirely, the same as the other hooks — worker_event_loop.cpp serializes the raw claim request body into a new otk_claim_ingest IPC frame; worker_pool.cpp's handle_otk_claim_ingest_request() invokes main's own unmodified provider under runtime.mutex. New integration scenario in tests/integration/test_federation_worker_flow.cpp seeds a one-time key, drives the handler directly, and asserts both that the response echoes the claimed key and that the key is gone from main's own store afterward. See docs/architecture.md, "Federation worker one-time-key claim relay".
  • fix(federation): ordinary messages and state changes never refreshed a federation worker's room snapshot — only the 7 membership-mutating room_service calls did, and pdu_sink's worker override deliberately never writes to the worker's own store either — so worker-served backfill/event/state/state_ids/get_missing_events queries for an active room could silently omit every message sent since its last membership-triggered reload: send_event() (room_service.cpp) was never among the calls that invoke notify_room_changed(), and nothing pushed an inbound-relayed message (via a worker's pdu_sink IPC call) back down to the relaying worker's own snapshot either. Fix: send_event() now also calls notify_room_changed(runtime, room_id), matching the placement convention of the existing 7 call sites; worker_pool.cpp's pdu_ingest IPC handler now calls notify_room_changed(env.room_id) after a successful pdu_sink commit, pushing the just-committed event back to whichever shard owns the room (in practice the same worker that made the call, since shard selection is a pure function of room_id). New send_event notifies the federation worker when an ordinary message is sent scenario in tests/unit/test_homeserver_room_service.cpp extends the existing test_room_changed_log-pinned contract to this eighth call site. See docs/architecture.md, "Federation worker room staleness".

0.10.20

Fixed

  • fix(federation): the federation worker's edu_sink was a hard no-op that silently dropped every inbound EDU it handled — including m.direct_to_device, the transport for E2EE megolm room-key shares — while logging them as dispatched rather than dropped: worker_event_loop.cpp set runtime.federation.edu_sink = {}; unconditionally with the stated reasoning "EDUs are ephemeral and acceptable to drop." True for m.typing/m.receipt/m.presence, but not for m.direct_to_device (carries m.room_key/m.room_key.withheld to-device messages) or m.device_list_update. Worse, inbound_request.cpp's transaction loop counts an EDU with no edu_sink installed as edus_dispatched, not edus_dropped — the transaction.accepted diagnostic and the 200 returned to the sending server gave no indication anything was lost. Reproduced against a real two-homeserver federation (matrix.ping.me.uk → pong.ping.me.uk, federation.worker.shards=2, the shipped example config): a megolm room-key share sent as an m.direct_to_device EDU landed on a worker shard and vanished; the recipient's client (matrix-sdk-crypto 0.18.0) logged Can't find the room key to decrypt the event, withheld code: None for every subsequent message encrypted with that session, and its PerSessionKeyBackupDownloader fallback also failed, since the key was never received by any recipient device in the first place — not even an m.room_key.withheld notice, since that's carried as an EDU too and would have been dropped identically. This is the same class of bug as the 0.10.19 membership_acceptor fix and the 0.10.14–0.10.18 room-staleness fixes, but for the one remaining unrelayed hook. Fix: worker_event_loop.cpp now overrides edu_sink the same way it overrides pdu_sink/membership_acceptor — serializes the InboundEduEnvelope (edu_type, origin, content_json) into a new edu_ingest IPC frame and calls main instead of handling (or dropping) it locally; content_json is embedded as an escaped JSON string via ipc::ipc_json_str, the same technique pdu_ingest's json field already uses, so nested content containing a literal "type" key (as m.room_key.withheld content does) cannot be confused with the outer frame's own "type" field. A new handle_edu_ingest_request() (worker_pool.cpp, exposed as a free function alongside handle_membership_ingest_request() for the same test-seam reason) receives the relayed request on main's side, reconstructs the envelope via federation::parse_inbound_edu_envelope(), and invokes main's own unmodified edu_sink under runtime.mutex — no separate sync_notifier->publish() call is needed, since every case inside main's edu_sink already publishes on its own success path. All EDU types are relayed uniformly (not just m.direct_to_device), so a future EDU type added to classify_edu_type without a matching drop-list update can't silently regress this again. New integration scenario in tests/integration/test_federation_worker_flow.cpp drives handle_edu_ingest_request() directly with an m.direct_to_device request shaped exactly like a real worker's edu_ingest frame and asserts the room-key share lands in main's own PersistentStore::to_device_messages — the same test-seam pattern the 0.10.19 membership fix established, for the same reason (a real end-to-end worker-subprocess round trip needs a live, network-resolvable remote signing key to clear the federation-policy gate ahead of edu_sink, orthogonal infrastructure this bug isn't about). See docs/architecture.md, "Federation worker EDU relay".

0.10.19

Fixed

  • fix(federation): a remote user's federated join/leave/knock — accepted correctly by a federation worker — was invisible to the main process's own room state, so every subsequent message from that member was rejected with "sender is not joined to the room": merovingian-fed-worker runs its own full HomeserverRuntime, and per its own documented design ("It does NOT write events — accepted PDUs are sent to main via pdu_ingest IPC and main commits them with the authoritative counter", worker_event_loop.cpp), regular /send transaction events already relay through pdu_sink to main for persistence. membership_acceptor — the handler backing send_join/send_leave/send_knock, wired by the same shared wire_federation_callbacks_impl() used for pdu_sink's default implementation — was never given the same treatment: it persisted straight into rt->database.persistent_store, which inside a worker process is that worker's own local, in-memory-cached store, completely bypassing main. So when a remote server joined a room via send_join (processed inside a worker), the join was durably written to the worker's own view of the database, but main's own PersistentStore — the one pdu_sink authorizes every later /send message against — never learned the member existed. Reproduced against a real two-homeserver federation (matrix.ping.me.uk → pong.ping.me.uk): the remote user's join succeeded (visible via a client-side sync), but their very next message was silently dropped by event_auth's step-10 "sender must be joined" check, logged only as a DEBUG-level authorization.rejected line with no error-level signal anywhere. This is a distinct bug from the earlier 0.10.14–0.10.18 federation-worker-staleness/shard-routing fixes: those were about a worker's view of a room going stale relative to main, or a room-sync notification landing on the wrong shard; this is the reverse direction — a worker unilaterally accepting a write that main, the authority pdu_sink checks against, never receives at all. Fix: runtime.federation.membership_acceptor is now overridden inside the worker process (worker_event_loop.cpp), mirroring pdu_sink's existing override exactly — it serializes the accepted endpoint (send_join/send_leave/send_knock) and the inbound PDU envelope into a new membership_ingest IPC frame and calls main over the same channel, rather than writing locally. A new handle_membership_ingest_request() (worker_pool.cpp, exposed as a free function alongside the existing federation_worker_shard_for() specifically so it has a test seam independent of the IPC machinery) receives the relayed request on main's side, invokes main's own (unmodified) membership_acceptor under runtime.mutex, and returns the MembershipAcceptResult — including the auth_chain/state snapshot the send_join response body requires — back across the channel. Unlike pdu_ingest's handler, this one does not need to separately call sync_notifier->publish(): the default membership_acceptor implementation already does that itself on success. New integration scenario in tests/integration/test_federation_worker_flow.cpp drives handle_membership_ingest_request() directly with a request shaped exactly like a real worker's membership_ingest frame and asserts the join lands in main's own PersistentStore (state and membership rows) — a real end-to-end round trip through a worker subprocess would additionally have to clear the unrelated "remote is unknown" federation-policy gate ahead of membership_acceptor in inbound_request.cpp, which needs a live, network-resolvable remote signing key; this file's other worker-pool scenarios don't clear that gate either, and asserting only status != 503 for all of them was a known, documented test gap closed only partially here. See docs/architecture.md, "Federation worker room staleness".

0.10.18

Fixed

  • fix(federation): inbound make_join/state/send_join/etc. against a room that genuinely exists locally could still 404 from the federation worker when federation.worker.shards is configured above the default of 1: federation_worker_room_id_from_request() extracted the room ID straight from the raw HTTP path segment (e.g. /_matrix/federation/v1/make_join/%21abc:example.com/...) without percent-decoding it, then hashed that raw string via federation_worker_shard_for() to pick which worker shard should handle the request. Every write path that keeps a shard's local PersistentStore in sync — notify_room_changed(), called from create_room/join_room/leave_room/invite_user/ban_user/kick_user/unban_user — instead notifies using the plain, already-decoded room ID (!abc:example.com), since that's the form stored internally and never touches a URL. %21abc:example.com and !abc:example.com hash to different shards whenever shards > 1, so the room-sync notification landed on a different worker than the one that would later receive the request for that room: the request-serving shard's local store never learned the room existed, and handle_make_membership() correctly (from its own perspective) returned 404 M_NOT_FOUND, reproducing as a permanent "Failed to make_join via any server" from the remote server no matter how many times it retried. Reproduced against a real two-homeserver federation (pong.ping.me.uk → matrix.ping.me.uk), where the room was demonstrably resident and the invite had already been delivered successfully. room_id_from_path_target() now percent-decodes the extracted segment via core::percent_decode_path_component() before it is returned, so shard selection uses the same canonical room ID string on both the write (notify_room_changed) and read (FederationProxy::handle) sides. This affects every room-scoped federation endpoint routed through federation_worker_room_id_from_request(), not just make_join. federation.worker.shards=2 is the shipped example config (config/merovingian.conf.example), so any deployment following it was exposed. New scenario in tests/unit/test_federation_request_routing.cpp pins that a percent-encoded room ID (%21room%3Aexample.com) in a make_join path decodes to the same room ID a plain, unencoded path would extract.

Testing

  • test(federation): the sharding test suite never asserted the invariant that actually broke above, and no test anywhere fed a percent-encoded room ID through the routing path — both gaps are why this bug shipped unnoticed: tests/integration/test_federation_worker_flow.cpp's existing "Room-scoped federation requests are routed to the correct shard" scenario only checked that each of two room IDs landed on some valid shard index (< shards), never that it was the same shard notify_room_changed() would use for that room — the actual property that matters, since a wrong-but-valid shard index is indistinguishable from a correct one by that check alone. It's strengthened with a new WHEN that spins up a real two-shard worker pool and asserts a request-derived room ID (plain and percent-encoded) matches, string-for-string, the plain room ID notify_room_changed() hashes directly, plus the resulting shard indices agree — string equality first because with only a couple of shards two different strings can coincidentally land in the same bucket, which would let a real mismatch slip past a shard-only comparison undetected (caught during development: an earlier version of this same test using only shard-index equality passed by 50/50 chance against the reintroduced bug). Verified against both states: fails deterministically with the percent_decode_path_component fix reverted, passes with it restored. Every unit-test scenario in tests/unit/test_federation_request_routing.cpp for room-ID extraction previously used an already-decoded string as input, so the missing decode step had no unit-level test surface to fail against either; tests/unit/test_federation_request_routing.cpp now exercises percent-encoded room IDs across all thirteen v1 and five v2 room-scoped endpoint prefixes (previously only make_join/send_join were covered), not just the two originally added alongside the fix above.
  • fix(federation): removed a dead, misleading room_endpoint_prefixes() entry for GET /_matrix/federation/v1/query/directory that could never match a real request: per spec (server-server-api.md#get_matrixfederationv1querydirectory), room_alias is a query parameter (?room_alias=...), not a path segment — confirmed against inbound_request.cpp's own handler, which reads it via query_param_value(). The path-segment-style prefix "/_matrix/federation/v1/query/directory/" therefore never matched any real request's shape and was dead code; it's removed, and this endpoint now explicitly documents that it falls through to shard 0 like any other non-room request, matching its actual (still incomplete) behavior. Fixing the routing extraction alone would not have made this endpoint correct: the room alias and the room_id notify_room_changed() partitions by are unrelated strings that hash to essentially independent shards, and database::reload_room() does not sync store.room_aliases per-room today regardless of which shard a request lands on. Properly sharding room-alias federation queries needs a real design decision (route to shard 0 and replicate room_aliases to every shard, resolve the alias in the main process before forwarding, or a dedicated non-sharded alias index) — tracked as a follow-up, documented in docs/architecture.md's "Federation worker room staleness" section ("Known gap: GET /query/directory cannot be sharded by this scheme at all"). The corresponding unit test scenario is updated to assert the (accurate, if still limited) current behavior instead of asserting only "a room ID was extracted," which was true but did not reflect the real, still-broken-for-shards>1 state.

0.10.17

Fixed

  • fix(sync): rooms.leave.<room_id>.timeline in the /sync response was always an empty events array, so a real client (Element/matrix-js-sdk) never actually saw itself leave even though 0.10.16 already fixed stream_ordering/sync_notifier and the server's own logs showed leave_count=1 delivered correctly: confirmed against Element's own network/console log — POST /leave returns 200 in 40ms, the in-flight long-poll GET /sync wakes and returns 200 with the room correctly keyed under rooms.leave, and the room still never disappeared from the client's room list. Per spec, rooms.leave.<room_id>.timeline is "the timeline of messages and state changes in the room up to the point when the user left" — matrix-js-sdk derives room.getMyMembership() by processing that timeline's state events, not merely from the room_id being present as a key, so an always-empty timeline gave the client nothing to act on. New build_leave_timeline_events_array() (client_server.cpp) looks up the user's current m.room.member state event for the room (authoritative even on an idempotent repeat /leave, since store.state is upserted in place by every real transition, including kicks and bans) and includes it as the room's sole timeline event, matching what real homeservers send. Strengthened the existing test_client_server.cpp leave-sync scenario to assert the timeline actually contains an m.room.member/membership: leave event, not just the room_id key.

0.10.16

Fixed

  • fix(sync,federation): a repeat /leave call on a room the caller already left server-side never healed a client stuck seeing it as joined: leave_room's idempotent "already left" branch (hit when the membership row is not "join"/"invite") returned 200 without calling persist_membership_transition, so it never advanced stream_ordering or notified sync_notifier/the federation worker. Reproduced from a real server's logs, right after upgrading to 0.10.15: a client's earlier leave had flipped membership to "leave" correctly, but under the pre-0.10.15 bug its stream_ordering stayed frozen, so /sync never told the client it had left; the client, still believing it was joined, retried /leave — and that retry hit the idempotent no-op path, which (correctly, per spec, on membership alone) did nothing further, leaving the row permanently stuck even across the restart onto the fixed build. A repeat /leave call is itself evidence the caller's view is stale, so the idempotent branch now also allocates a fresh stream_ordering, persists it via store_or_update_membership (membership value unchanged), publishes to sync_notifier, and calls notify_room_changed() — self-healing rows left in this state by the 0.10.15-era bug without requiring a manual database repair. New regression scenario in tests/unit/test_homeserver_room_service.cpp pins that a second leave_room call advances stream_ordering past the first and re-notifies the federation worker.

0.10.15

Fixed

  • fix(database): update_membership() silently dropped stream_ordering on every membership transition, freezing /sync's only signal that a membership row changed: POST /leave (and every other membership transition on a pre-existing row — invite, kick, ban, rejoin) went through store_membership() first, which only succeeds on a brand-new row; an existing row instead fell through to update_membership(), whose UPDATE membership SET membership = $3 ... never touched the stream_ordering column, and the in-memory PersistentMembership::stream_ordering was left untouched too. The row's membership value flipped correctly, but its stream_ordering stayed pinned at whatever it was on first insert (frequently 0), so any later since-token comparison against it (joined_membership_changed_since, and the rooms.leave inclusion check below) could never see the change as "recent." update_membership() now takes a stream_ordering parameter, persists it in the same UPDATE (stream_ordering = $4), and writes it to the in-memory row; all nine call sites (room_service.cpp, local_http_router.cpp) now pass the same counter value used for the corresponding store_membership() attempt.
  • fix(sync): a room the caller just left never disappeared from their client, because /sync omitted it from rooms.leave unless the (rarely set) include_leave filter was true: reproduced from a real server's logs — POST /leave returned 200 and room.leave.accepted, but the room stayed in the client's list forever, and a repeat /leave also silently no-op'd (membership was already "leave" server-side). Per spec, a room left before the sync window began is correctly omitted unless include_leave: true — but a leave/kick/ban that happens within the current window is a state change the client must be told about regardless of that flag, since most clients (matrix-js-sdk, Element) never set it. rooms.leave in the incremental-sync path now also includes a "leave" membership when membership.stream_ordering > since_ordering, i.e. it changed after the caller's last sync; already-known leaves (older than since) remain gated behind include_leave as before. Depends on the update_membership fix above — without it, stream_ordering never actually advances on the leave, so this check alone would not have been sufficient.
  • fix(federation): invite_user/ban_user/kick_user/unban_user never notified the federation worker of the membership change, so a remote user invited to an existing, already-resident room could get a permanent 502 Failed to make_join via any server / 404 from make_join: the 0.10.14 federation-worker-staleness fix wired notify_room_changed() into create_room/join_room/leave_room — every call that changes this server's own residency in a room — but missed the four calls that mutate a second user's membership on a room this server is already resident in. Reproduced from a real server's logs: a local user creates a room and invites a remote Synapse user; the remote server's make_join 404s from the worker on every retry, indefinitely, because the worker's store.rooms snapshot never learned the room existed (its own creation-time notification can be silently dropped if the worker's IPC channel wasn't yet healthy — a pre-existing, separate gap — and nothing else ever re-notifies for that room_id once only invites/kicks/bans happen against it afterward). All four now call notify_room_changed(runtime, room_id) after committing the membership change, matching the existing pattern; tests/unit/test_homeserver_room_service.cpp gains one test_room_changed_log-pinned regression scenario per function, alongside the three already covering create/join/leave. See docs/architecture.md, "Federation worker room staleness".

0.10.14

Fixed

  • fix(federation): send_join still failed for a genuinely huge room with 502 send_join failed: response_too_large, even after the join_timeout/base64-framing fixes in 0.10.13 gave it enough time and IPC headroom: OutboundClient rejects any response body larger than http::OutboundRequest::max_response_body_bytes, a hardcoded 16 MiB used by every federation call — but a send_join response embeds the room's entire current state (one m.room.member per member plus the auth chain), and for a room like matrix.org's #community with tens of thousands of members that routinely exceeds 16 MiB long before any timeout or IPC frame limit is reached. New security.federation.join_response_max_size (default 64MiB) gives make_join/send_join their own response-size budget, threaded through a new OutboundCall::max_response_body_bytes field (build_outbound_request forwards it to OutboundRequest) and a new perform_sync_outbound_call parameter that send_join's call site populates from the config value; every other federation call keeps the 16 MiB default. Because the federation-worker IPC channel's outbound_http_response frame carries the base64-encoded body (see the 0.10.12 entry below), raising this cap also has to raise the channel's max_frame_bytes — a new ipc::frame_bytes_for_response_cap() computes the required frame size from the configured response cap, and WorkerPool (main process) and WorkerEventLoop (worker process) each derive it independently from the same config so both sides of the channel agree without negotiating it over IPC itself. This value is baked in at worker-spawn time, so unlike the other join_* keys it is restart_required, not hot-reloadable — reload_policy_for_key now special-cases it.
  • fix(federation): inbound make_join/state/etc. against a room this server just became resident in (via create_room, a federated join_room, or a local join) could 404 from the federation worker even though the room fully exists in the database: merovingian-fed-worker runs its own HomeserverRuntime with its own PersistentStore, hydrated once from disk at worker startup — every write helper (store_room, store_membership, ...) updates the database and that same process's in-memory vectors together, but nothing pushes a write made by the main process into a worker's already-running copy. Since inbound federation requests are routed to the worker, any room created or joined after the worker's last (re)start was invisible to it: a remote server's make_join against this server for that room hit the worker's stale store.rooms lookup and 404'd, even though the room was correctly persisted and the main process's own copy of the store had it — this is the root cause behind the "Failed to make_join via any server" report traced back through merovingian-server's logs showing a room and membership present in sqlite3 but a live 404 from the same server. Fix: create_room/join_room/leave_room now call HomeserverRuntime::federation_proxy->notify_room_changed(room_id) after committing a residency change; FederationProxy/WorkerPool::notify_room_changed resolve the owning shard and send a fire-and-forget room_sync IPC notification (ipc::serialize_room_sync_notification), and the worker's handler responds with a new database::reload_room(), which re-reads just that room's rows (room, membership, invites, events, state, and the event-relation tables scoped to that room's events) from the database via parameterised, room_id-scoped queries — implemented for both SQLite and PostgreSQL — and replaces the worker's in-memory slice for that room, not a full re-hydration. Best-effort: a dropped notification is not surfaced to the caller. See docs/architecture.md, "Federation worker room staleness".
  • fix(database): PersistentEvent::prev_event_ids/auth_event_ids/signatures silently read back empty after any process restart, not just in the new federation-worker reload path above: hydrating a PersistentStore from disk only loaded the flat event_edges/event_auth/event_signatures tables into their own top-level vectors; nothing joined those back onto the matching PersistentEvent, which only carries populated DAG-linkage fields when an event is stored fresh within a process's own lifetime (store_event_with_state). This silently produced make_join/state responses with empty prev_events/auth_events for any room whose events were written before the current process started — no crash, no error, just a request-shaped-wrong response. New database::reconstruct_event_relations() re-derives these fields from the flat tables; called at the end of both open_sqlite_persistent_store/open_postgresql_persistent_store and as part of reload_room's snapshot merge.

Added

  • test(federation): enforce the "every room-residency change notifies the federation worker" design contract directly, rather than only through the reload_room/reconstruct_event_relations/room_sync unit and integration tests above: proving a real, separate merovingian-fed-worker process answers correctly for a room the main process just created turns out to be architecturally hard to test faithfully — the worker independently re-resolves the caller's identity even for a pre-verified request, which requires clearing a hardcoded SSRF check with no override reachable from outside the process (not a live test server, not a pre-seeded key cache, nothing short of weakening that check). New HomeserverRuntime::test_room_changed_log (test-only, always nullptr in production) lets tests assert the design contract directly and cheaply instead: create_room/join_room/leave_room's six call sites are consolidated through one function, notify_room_changed() in room_service.cpp, which appends to this log when a test wires it, in addition to notifying federation_proxy. New scenarios in test_homeserver_room_service.cpp pin all three operations (plus a negative case proving an unrelated room is never logged); a new assertion in test_join_room_flow.cpp's existing live federated-join test pins the real network round-trip path the same way. Fixes a genuine lifetime hazard found while writing the live-join assertion: the background member-fill task holds a reference to the runtime and can still call notify_room_changed() after a THEN block returns (the runtime's destructor blocks draining it, but the test doesn't wait explicitly) — the log vector must be declared before the runtime variable so it destructs after the runtime's blocking dtor, or the background task can write through a dangling pointer (reproduced as a corrupted size vs. prev_size in fastbins heap-corruption abort before the fix). See docs/architecture.md, "Federation worker room staleness".

0.10.13

Fixed

  • fix(federation): send_join IPC times out for large rooms because it uses remote_timeout_seconds instead of the join budget: send_join was calling perform_sync_outbound_call with runtime.federation.config.remote_timeout_seconds, the same short budget used for key and discovery fetches, instead of join_timeout_seconds — despite the join_timeout_seconds field being documented as "Separate budget for make_join/send_join/make_leave/send_leave". For a 30,000+ member room, matrix.org can take well over 60 s to assemble and transmit the full room state, so the IPC channel timed out and the main process returned 502 send_join failed: IPC timeout waiting for outbound HTTP result. send_join now mirrors the same fallback chain as make_join (lines 2877-2879): join_timeout_seconds when configured, else remote_timeout_seconds.
  • fix(federation): perform_sync_outbound_call with timeout_seconds=0 (unconfigured) produced a 10 s IPC window instead of using the FederationCall default (60 s): WorkerPool::send_outbound_request computes the IPC channel timeout as total_timeout_seconds + 10. When neither join_timeout_seconds nor remote_timeout_seconds is set, timeout_seconds=0 was written directly to call.total_timeout_seconds, overwriting the FederationCall default of 60 s and collapsing the IPC window to just 10 s. perform_sync_outbound_call now guards the assignment behind timeout_seconds > 0; a zero value leaves the FederationCall defaults untouched.
  • fix(config): config/merovingian.conf.example was missing every security.federation.join_* key and federation.worker.apply_hardening: the example config documented security.federation.remote_timeout but never mentioned join_timeout, join_parallelism, join_race_deadline, join_max_candidates, or join_state_key_parallelism — the five keys added across 0.10.10/0.10.11 specifically to give the join/leave membership dance its own extendable budget, separate from the general 60 s remote_timeout. Operators following the example file had no way to discover these keys existed (they are documented in docs/configuration.md, but not surfaced in the file operators actually copy from). All five are now present with their code defaults (180s, 8, 45s, 20, 100). federation.worker.apply_hardening (seccomp/capability sandboxing for the federation worker, default true) was also missing and is now documented alongside the other federation.worker.* keys.

0.10.12

Fixed

  • test(federation): lock runtime.mutex before reading persistent_store in test_join_room_flow.cpp's immediate-success assertions, fixing a real ThreadSanitizer-detected data race: the CI tsan job aborted (SIGABRT, killed by signal 6) with a genuine race reported at src/database/persistent_store.cpp:978 in store_membership — not a timeout. join_room returns as soon as critical state is persisted and hands the bulk membership fill to a background task tracked in runtime.orphan_futures_, which writes to persistent_store.state/.memberships under runtime.mutex (see the 0.10.11 fast-join entry below). The test's first THEN block read those same containers on the main thread immediately after join_room returned, with no lock at all, while that background task was still running and concurrently calling store_membership's emplace_back — a real unsynchronized read/write race on the vector, exactly what TSan is designed to catch. Every production call site accesses persistent_store only while holding runtime.mutex; the test now does the same. The second THEN block was already race-free: it calls .wait() on every future in orphan_futures_ before reading, which establishes a happens-before edge with the background task's writes.
  • fix(ipc): base64-encode response bodies relayed over the federation-worker IPC boundary so large-room send_join responses stop busting the frame cap (#342): joining a large room reliably failed with a client-visible 502 send_join failed: timeout, even after a candidate resident server answered send_join successfully. fed_response and outbound_http_response frames (src/ipc/federation_ipc_frames.cpp) embedded the HTTP response body via raw JSON-string escaping, whose expansion depends on content: every "/\ doubles and every raw control byte becomes a 6-byte \u00XX escape. A send_join response for a large room routinely reaches http::OutboundRequest::max_response_body_bytes (16 MiB by default) — quote-dense room-state JSON at that size escapes to more than the (then-equal) 16 MiB merovingian::ipc::kIpcMaxFrameBytes cap, so IpcChannel::send_response silently dropped the oversize frame (ipc: dropping oversize response frame) and WorkerPool::send_outbound_request on the main side just timed out waiting, surfacing as room.join.remote.send_join_failed reason="IPC timeout waiting for outbound HTTP result". The body is now base64-encoded before framing (base64_encode_body/base64_decode_body), a fixed, content-independent 4/3 expansion, and kIpcMaxFrameBytes is raised to 24 MiB to hold a max-size response with headroom to spare. Also corrects a stale docs/hardening.md/header comment claiming the frame cap is configurable via federation_worker.max_ipc_frame_bytes; no such config field exists — the cap is a compile-time constant.

Added

  • test(federation): live join_room integration test against a real local TLS server, closing the codecov/patch gap from #341: join_room's post-send_join-success path (fast-join state splitting, signature verification, background membership fill) had no integration coverage because perform_sync_outbound_call always resolves destinations through federation::discover_server(), which unconditionally rejects loopback/private-range addresses (src/federation/security.cpp ip_address_is_private_or_loopback, no config or test-mode override) — so a local test server can never be reached through the real discovery path, and production outbound calls never populate OutboundRequest.trusted_ca_pem, so a self-signed test certificate would fail TLS verification even if discovery succeeded. HomeserverRuntime gains a test-only outbound override (a map from destination server_name to a forced resolution + trusted CA PEM), consulted first in perform_sync_outbound_call; unset in every production construction path, so real-server SSRF and TLS-trust behaviour is unchanged. New tests/integration/test_join_room_flow.cpp reuses the TLS test-certificate/TcpAcceptor pattern from test_federation_outbound_flow.cpp to stand up a real resident server answering make_join/send_join, driving join_room through an actual signed federation round trip.

0.10.11

Added

  • feat(federation): fast join — verify and persist critical room state synchronously, defer the bulk membership list to a background task: even with bounded-parallel key resolution, a join_room call that waits on verifying every member's m.room.member event before returning still pays a real cost proportional to the number of distinct member home servers in the room — for a 30,000-member room, potentially hundreds of key resolutions before the client sees success. New split_send_join_state_events separates a send_join response's state array into "critical" state (create, power_levels, join_rules, history_visibility, encryption, our own membership — everything an auth check on the joining user's own next action could depend on) and "background" state (every other member's m.room.member). Critical state and auth_chain are verified (network-bound key resolution now runs before guard.lock(), so it never holds runtime.mutex) and persisted before join_room returns; background state is verified and persisted by an async task tracked in the existing orphan_futures_ queue (same drain-on-shutdown guarantee as a losing make_join race candidate), logging room.join.background_state_complete on completion. The verify-before-persist invariant is unchanged for every event — nothing is exposed via /sync before its signature is checked, regardless of timing. This is a deliberately bounded-scope version of the "partial state room" trade-off Synapse's faster-joins feature makes: room_has_member()/the /members endpoint may not list every member until the background fill completes, but no auth-critical state is ever deferred. See docs/threat-model.md.

Fixed

  • fix(security): verify send_join state/auth_chain event signatures instead of trusting the resident server wholesale, with bounded-parallel key resolution: join_room's ingestion of a send_join response's state and auth_chain arrays persisted every event straight to the event graph with no Ed25519 signature check and no remote signing-key fetch — a direct violation of src/federation/AGENTS.md rule 2 ("Verify every inbound PDU's signature... Unverified events must be silently dropped"), and the actual bottleneck behind "there are 30,000+ users that need checking" for a large room: doing this correctly means resolving a signing key for every distinct member home server represented in the room, not just racing candidate servers faster. New filter_verified_send_join_events resolves distinct (sender_domain, key_id) pairs across both arrays via the existing remote_key_resolver/key-cache with concurrency capped by a new security.federation.join_state_key_parallelism (default 100, reloadable) — deliberately not unbounded, since a 30,000-member room can span hundreds of distinct home servers and firing all of them at once would just trade one unbounded-fan-out bug for another. Events whose sender is our own server are trusted without a resolver round trip. Fail-closed: an event whose key cannot be resolved, or whose signature does not verify, is silently dropped rather than persisted — and does not fail the join, so one uncooperative or unreachable member home server degrades that member's row rather than blocking everyone else's.
  • fix(federation): advertise all server-supported room versions (v1-v12) in outbound make_join, not a hardcoded v10-v12: joining any room not on room version 10, 11, or 12 failed against every real resident server — including matrix.org — with 400 (M_INCOMPATIBLE_ROOM_VERSION), because join_room's outbound GET /make_join request only ever sent ver=10&ver=11&ver=12. This was a pure omission: rooms::room_version_policy.cpp already fully implements and conformance-tests (test_room_version_table_conformance.cpp, "a server MUST be able to participate in rooms of all stable versions") auth rules, redaction rules, event format, and event-ID computation for v1 through v12, and the rest of the join pipeline (validate_make_join_event, sign_event_for_server, make_reference_hash_event_id) already dispatches generically on whatever room_version the response reports. supported_versions is now derived from rooms::known_room_versions() (the same source of truth used elsewhere) instead of a literal. A large, long-lived room (the kind likely to have tens of thousands of members) is essentially never on the three newest versions, so this was silently failing every federation join to such rooms. GET /_matrix/client/v3/capabilities's m.room_versions.available had the identical hardcoded-subset bug (misleading clients about which versions are valid to create/upgrade rooms to) and is fixed the same way.
  • fix(federation): bound the make_join race with an overall deadline and cap candidate count: joining a large, well-federated room (e.g. via a via list spanning dozens of resident servers) could grind for 8+ minutes — the 0.10.10 parallel race capped concurrency (join_parallelism, default 8) but not total elapsed time: with N candidates and a per-call budget up to join_timeout (180s), the race took roughly ceil(N/join_parallelism) * up_to_180s with no upper bound, while the client's own fetch (and any reverse proxy in front) gave up long before the server did, surfacing as "Failed to fetch" even though the server kept working. Two new independent config keys fix this: security.federation.join_race_deadline (default 45s, reloadable) bounds the entire race — not just each candidate — after which join_room returns 502 with candidates still in flight parked in orphan_futures_ (unchanged draining behaviour); security.federation.join_max_candidates (default 20, reloadable) truncates the ordered candidate list before any make_join probe is spawned, since every candidate — regardless of join_parallelism — got an OS thread immediately via std::launch::async, so an unbounded via list meant unbounded upfront thread spawning. Both default to values that preserve the 0.10.10 behaviour for small via lists while guaranteeing a bounded, real HTTP response for large ones.
  • test: cover the race deadline, candidate cap, room-version advertisement, and send_join signature verification: new scenarios in test_config_federation_join.cpp (defaults, parsing, validation, hot-reload classification for the three new join-related keys) and test_join_routing.cpp (cap_join_candidates — order-preserving truncation, no-op when under cap, 0 clamped to 1); test_homeserver_room_service.cpp gains scenarios exercising join_room with both a configured deadline and a candidate list exceeding join_max_candidates, confirming truncation and the distinct "race deadline exceeded" vs. "all candidates exhausted" rejection reasons. test_client_server.cpp's capabilities scenario now asserts all twelve versions (v1-v12) are advertised as stable, not just v10-v12. test_federation_invite_join.cpp gains five filter_verified_send_join_events scenarios built on real signed test events (tests/federation_signing_test_support.hpp): a validly signed event survives, an event with an invalid signature is dropped, an event whose sender-domain key cannot be resolved is dropped, a self-signed (our-server) event is kept without invoking the resolver at all, and — mirroring the concurrency-proof pattern already used for the inbound parallel PDU sender-key resolver — six distinct sender domains resolve exactly once each with measured peak concurrency never exceeding a configured join_state_key_parallelism=2. Six more scenarios cover split_send_join_state_events: room-level state events (create, power_levels, join_rules, history_visibility, encryption) stay critical; the joining user's own membership stays critical; other members' membership is deferred; a realistic mixed array splits correctly; a malformed/unclassifiable entry defaults to the synchronous critical path rather than being silently dropped; and an empty input returns an empty split.

0.10.10

Fixed

  • fix(federation): configurable join timeout for make_join/send_join/make_leave/send_leave: the make_join call used the shared 60s security.federation.remote_timeout, so joining a large remote room (e.g. on matrix.org) timed out with 502 make_join failed: timeout when the resident server's make_join was slow. A new security.federation.join_timeout (default 180s, reloadable) gives join/leave membership calls a separate, extendable budget, independent of the 60s general federation timeout.
  • fix(federation): race make_join across candidate servers in parallel: the candidate loop tried each via/server_name resident server sequentially with the full timeout each. make_join now runs against all candidates concurrently (capped by security.federation.join_parallelism, default 8, reloadable) and the first success wins; send_join targets that server (spec §Joining Rooms). If one via server is fast and another is slow, the join completes at the speed of the fastest, and a genuinely-slow-but-eventual resident server gets the full join_timeout budget.
  • fix(federation): TTL discovery cache for server-name resolution: the remote key resolver re-ran the full .well-known + SRV + DNS discovery cascade on every call, even on cache hits. A new CachedServerDiscovery (60s TTL, negative cache, thread-safe, mutex-guarded) wraps the discovery network so cache-hit lookups skip the cascade, removing repeated DNS latency from key resolution and outbound calls. SSRF pinned_addresses are never returned stale past TTL.
  • fix(federation): parallel inbound PDU sender-key resolution: the inbound /send handler resolved each PDU sender's signing key serially. Distinct (sender_domain, key_id) pairs across the transaction are now resolved concurrently (capped by join_parallelism) before per-PDU verification, while preserving fail-closed per-PDU rejection and the HTTP 200 + per-PDU error-map response (returning 4xx/5xx makes Synapse back off the whole destination).

  • test: extend join_room and inbound parallel resolver coverage: added eight new Catch2 BDD scenarios covering previously-untested branches: join_room rejects unauthenticated callers (401), returns 404 when no candidate servers exist (room on own domain, no via), returns 200 idempotently when the user is already a local member, and the parallel race with join_parallelism=0 (clamped to 1), join_timeout_seconds>0 (uses join budget over remote timeout), and five candidates throttled by a parallelism=2 semaphore. Two new inbound scenarios cover join_parallelism=1 (serial path, peak concurrency ≤ 1 verified) and join_parallelism=0 (clamped to 1, all PDUs accepted).

  • test: cover the CachedServerDiscovery-backed remote_key_cache overloads and the HomeserverRuntime orphan-future destructor: the fetch_remote_server_keys(client, CachedServerDiscovery&, ...) and make_persistent_remote_key_resolver(store, client, CachedServerDiscovery&, ...) overloads — the pair actually wired by start_runtime/local_http_router.cpp whenever cached_discovery is available — had zero direct test coverage; only the raw-ServerDiscoveryNetwork overloads and the standalone cache helpers were exercised. Three new scenarios in test_remote_key_cache.cpp prove: a failed discovery fails the fetch closed without an outbound call and is served from the negative cache on retry; a fresh cached key is served without repeating the resolver's own discovery lookup across two resolver calls (network.lookup_calls == 1); and a resolver with no cached key and failing discovery returns nullopt on both calls with discovery deduped. A fourth new scenario in test_homeserver_room_service.cpp directly exercises HomeserverRuntime::~HomeserverRuntime()'s orphan-future drain: a slow future is parked in orphan_futures_ and the runtime is destroyed while it is still running, asserting the destructor blocks until it completes rather than returning immediately.

Deferred: multi-server signing-key batching via POST /_matrix/federation/v1/query/keys — that endpoint is for E2EE device keys, not server signing keys; the v1.18 spec defines no signing-key batch endpoint (signing keys are single-server GET /_matrix/key/v2/server). Parallel fan-out achieves the same wall-time without a spec deviation or a new untested inbound handler.

0.10.9

Fixed

  • fix(ipc): authenticate the federation-worker IPC key exchange (#318): the IpcChannel crypto_kx handshake was unauthenticated, so any process able to reach the inherited AF_UNIX fd could complete the handshake and inject AEAD frames. Both the main process and the worker now derive the same 32-byte IPC auth key from the operator master-key file (the same material used for v4 access-token keys) and MAC each other's ephemeral KX public keys with a domain-separated key (merovingian:ipc-channel-auth:1) before deriving session keys. Peers that cannot prove possession of the master key are rejected (fail-closed). load_master_key_material is lifted into a shared header so both processes link the same loader.
  • fix(ipc): replace the hand-rolled IPC JSON parser with the fuzzed canonicaljson parser (#320): IPC frame bodies were parsed with json.find("\"key\":\"") substring searches that broke on escaped quotes, keys appearing inside string values, or } inside header values, and std::from_chars results were not checked. All deserialize_* functions in src/ipc/federation_ipc_frames.cpp, the id/reply_to extraction in src/ipc/channel.cpp, and the duplicated parsers in src/federation_worker/worker_event_loop.cpp now use the existing depth-bounded, fuzzed canonicaljson::parse_json(). Malformed frames are rejected instead of misparsed.
  • fix(ipc): stop noexcept IPC frame functions terminating on allocation failure (#324): IpcChannel::write_frame/read_frame were marked noexcept but allocated std::vector/std::string up to the frame cap; std::bad_alloc would call std::terminate. The allocations are now wrapped in try/catch(std::bad_alloc) returning false/std::nullopt, preserving the noexcept contract and letting callers fail gracefully.
  • fix(ipc): lower the IPC frame cap and stop silently dropping oversize responses (#325): the 50 MiB per-frame cap enabled memory-exhaustion DoS across concurrent workers. The default is lowered to 16 MiB and made configurable via federation_worker.max_ipc_frame_bytes. Oversize frames are now logged and propagated as send/request failures instead of being silently dropped.
  • fix(homeserver): don't inherit the full parent environment in the federation worker (#330): posix_spawn was called with envp = nullptr, leaking every parent environment variable (including any secrets) to the lower-privilege worker. The supervisor now passes an explicit minimal environment (PATH only) to the worker child.
  • fix(platform): apply a worker-specific seccomp filter and runtime hardening to merovingian-fed-worker (#319): the worker binary inherited the main process's more-permissive seccomp filter (which deliberately allows execve/execveat so the worker could install a stricter one) but never installed its own. The worker now applies PR_SET_NO_NEW_PRIVS, drops capabilities, sets resource limits, and installs a stricter seccomp-bpf filter that denies execve/execveat/spawn-oriented clone (the worker never execs or spawns). docs/hardening.md and the seccomp self-check are updated.
  • fix(platform): disable exit-time LeakSanitizer in the federation worker under ASan (#319): the worker's seccomp filter denies ptrace by design (an escalation primitive a compromised worker must never have). ASan's exit-time LeakSanitizer uses ptrace (via StopTheWorld) to suspend threads before scanning for leaks; with ptrace denied, the tracer thread is killed and StopTheWorld spun in sched_yield forever, so the worker process never exited and the supervisor's waitpid() hung — producing the asan-ubsan CI integration-test timeout. The worker now defines __asan_default_options() returning detect_leaks=0 (C linkage, global scope, guarded to ASan builds only) so the worker skips the exit leak check; the main process retains full ASan/LSan coverage and the worker's correctness is covered by its own unit/integration tests. Production (non-sanitised) builds are unaffected.
  • fix(auth): derive the legacy v3 access-token HMAC key from the master key instead of the Ed25519 seed (#322): token_hmac_key_v3 copied the first 32 bytes of the Ed25519 signing seed and used it directly as the access-token HMAC key, violating cryptographic key separation. The v3 key is now master-key-derived with a distinct domain separator (merovingian:access-token-hmac:legacy-v3:1). Breaking: existing token-hash:v3: hashes no longer validate; affected sessions must re-login and are issued v4 tokens (the current issuance path). The signing seed is no longer used as a MAC key. Without a master key configured, v3/v4 are both unavailable and token issuance falls back to the unkeyed v2 hash; configure a master key for hardened token hashing.
  • fix(database): mark the server signing secret as sensitive for prepared-statement redaction: store_server_signing_key bound the secret_key column as a public_value, so sensitive_values_are_redacted flagged the store once a master key was configured (the at-rest encrypted form secretbox:v1:… contains the substring "secret"). The signing secret is now bound as sensitive_value, consistent with the other key/signature parameters — the encrypted-or-plaintext signing secret is always redacted from query-parameter logging.
  • fix(federation): handle escaped quotes in the X-Matrix Authorization header parser (#321): parse_x_matrix_authorization_header ended a quoted value with remaining.find('"'), ignoring \" escapes per RFC 7230, so legitimate headers containing escaped quotes failed to parse and attacker-controlled headers could terminate values early. The parser now skips \"/\\ escape sequences when scanning for the closing quote.
  • fix(crypto): keep the server signing secret in core::SecretBuffer, not std::string (#317): multiple call sites copied the Ed25519 signing secret out of the mlocked/zeroised SecretBuffer into a heap std::string via reinterpret_cast, which is not pinned or zeroised and may leak into freed arenas or core dumps. make_federation_signature, OutboundCall::secret_key, DispatchWorkerConfig::secret_key, and perform_sync_outbound_call now accept std::span<std::uint8_t const> / own a core::SecretBuffer; call sites pass signing_secret_key.bytes() directly.
  • fix(ipc): stop shipping raw peer X-Matrix credentials to the federation worker (#323): inbound fed_request frames serialized the raw peer Authorization header (origin/key/sig) into the IPC body, letting a compromised worker harvest and replay peer homeserver credentials. The main process now verifies the inbound X-Matrix signature itself (verify_inbound_federation_signature) and forwards only the verified identity (origin, key_id, sig_verified) to the worker over the now-authenticated channel; the raw access_token/signature never crosses IPC, and Authorization/X-Matrix headers are stripped from the fed_request frame. The worker's handle_federation_http_request builds a SignedFederationRequest from the verified identity (signature_verified = true) and skips re-verification. Outbound residual (not addressed by this change): the outbound Authorization header carried on outbound_http_request frames is our own request-bound X-Matrix signature (bound to the method/url/body/destination of the exact request the worker sends), not a reusable peer credential, so the harvest/replay risk that motivated this issue does not apply outbound; the signing secret never enters the worker (#317). Relocating outbound signing into the worker via IpcEd25519Provider — so the signed value never crosses IPC — is deferred; it requires a provider-abstraction refactor of build_outbound_request for minimal additional security value.

0.10.8

Fixed

  • fix(platform): seccomp filter SIGSYS crash on glibc 2.35+ when built with older kernel headers: rseq (Linux 4.18), membarrier (Linux 4.3), getcpu, and futex_waitv (Linux 5.16) were conditionally included in the BPF allowlist only when the build-time kernel headers defined the corresponding __NR_* macros. On hosts built against Ubuntu 18.04 headers (Linux 4.15) or Ubuntu 22.04 headers (Linux 5.15), these macros are absent and the filter silently drops the entries. When the binary then runs on a host with glibc 2.35+, thread initialisation unconditionally calls sys_rseq(), which is blocked by SECCOMP_RET_KILL_PROCESS — delivering SIGSYS and killing the server immediately after event=start.complete. Fixed by adding architecture-specific numeric fallbacks matching the pattern established in v0.10.6 for clone3 (435), close_range (436), and faccessat2 (439): x86_64 fallbacks are 334/324/309/449; aarch64 fallbacks are 293/283/168/449.
  • fix(platform): seccomp filter SIGSYS crash in ThreadSanitizer federation worker: the production seccomp allowlist omitted personality (135). ThreadSanitizer calls personality(ADDR_NO_RANDOMIZE) in the federation worker after execve to disable ASLR for deterministic shadow-memory layout. Because the worker inherits the server's seccomp-bpf filter, the call was killed with SIGSYS before the IPC handshake, producing "Federation worker failed to start: ipc: public key exchange failed" in the TSan integration test. personality is now explicitly allowed in the filter.
  • fix(federation): orphaned federation workers persist after parent crash/kill in tests: the federation worker now calls prctl(PR_SET_PDEATHSIG, SIGTERM) immediately after startup so the kernel terminates the child automatically when the spawning parent thread exits. This prevents leftover worker processes from spinning at 100% CPU when integration tests kill the server process or when the server crashes.
  • fix(tests): binary startup hardening test failed in CI containers that run as root: tests/integration/test_server_startup_hardening_flow.cpp now skips (WARN) when the test process runs as root, because the server refuses to start as root by design (the privilege-drop and filesystem-restriction self-checks report disabled) and the test can never reach the listening state. This fixes the blocking debian/fedora/rhel/opensuse CI jobs, which run in containers as root. A pre-REQUIRE(ready) refusal check also skips when the server logs Startup refused: hardening self-check before binding listeners.
  • fix(tests): binary startup hardening test crashed under ThreadSanitizer: the TSan-instrumented server is killed by the seccomp filter on startup (TSan's runtime issues syscalls the allowlist does not permit beyond personality), which surfaced as a signal-unsafe call inside of a signal in the Catch2 fatal-error handler. The test now skips under __SANITIZE_THREAD__ / __has_feature(thread_sanitizer); the asan-ubsan CI job continues to exercise the same startup path.
  • fix(tests): hardening self-check unit test failed when the test process ran as root: tests/unit/test_hardening_self_check.cpp previously asserted indices 9 (privilege drop) and 10 (filesystem restrictions) were enabled or unknown. On Linux these are now definitive — enabled for a non-root UID, disabled for root — so the assertion failed in root CI containers. Indices 9/10 are now split out and asserted per-platform: enabled/disabled on Linux, unknown elsewhere.
  • fix(tests): NetBSD compile error in wait_for_log_line: tv.tv_usec was assigned via static_cast<long> and then implicitly narrowed to suseconds_t (aka int) on NetBSD, triggering -Wshorten-64-to-32 (treated as error). Cast directly to suseconds_t; the value (0..999000) always fits.
  • fix(tests): hardening self-check unit test failed on NetBSD: the platform-specific sandbox scenario in tests/unit/test_hardening_self_check.cpp had an #else branch (covering NetBSD and any platform that is not Linux/OpenBSD/FreeBSD) that asserted pledge/unveil and capsicum report unknown. The implementation correctly reports enabled for controls that are not applicable to the host OS (no security gap — the same treatment Linux receives for these BSD-only primitives); unknown would wrongly block startup via is_ready() on platforms that can never apply them. The #else branch now asserts enabled for both, matching the implementation and the v0.10.8 "no alpha exceptions" design. This was the last failing NetBSD CI job (1 of 886 unit cases, exit status 42).
  • ci(netbsd): surface the Catch2 failure report from testlog.txt on unit-test failure: the NetBSD job's meson test --print-errorlogs truncated output to the last 100 lines, which were all structured runtime INFO logs — Catch2's own failure report (test name + file:line) was buried, and the core-backtrace group was empty because assertion failures do not produce cores. The failure branch now filters runtime log lines and [netbsd-diag] markers out of build/meson-logs/testlog.txt and prints the Catch2 section headers, FAILED assertions, and test-case summary to the live log.

Changed

  • refactor(platform): remove HardeningStatus::alpha_exception and require every hardening check to report enabled: the alpha-phase carve-out status is deleted from the hardening self-check. is_alpha_ready() is replaced by is_ready(), which returns true only when every hardening check is enabled; unknown and disabled both block startup. src/main.cpp now runs the final self-check after all platform controls are applied: after start_client_server() has applied the OpenBSD pledge/unveil profile and after FreeBSD cap_enter() has entered Capsicum capability mode. docs/hardening.md is updated to remove all alpha-exception language.
  • test(platform): binary startup integration test skips cleanly when the environment cannot satisfy full hardening: tests/integration/test_server_startup_hardening_flow.cpp now detects a hardening-gate refusal from the server log and returns with WARN(...) instead of failing, so Meson (which treats Catch2 SKIP() exit code 4 as failure) reports a clean skip. wait_for_log_line preserves all drained output so the refusal line is not lost when it arrives in the same read() as "Listeners active". The refusal is checked in every poll iteration and again after the child exits, so a server that refuses startup and immediately terminates is still reported as a skip. ServerGuard::alive() uses waitpid(WNOHANG) so a server that exits between "Listeners active" and the idle check is not misreported as alive due to zombie PID reuse. The idle-window failure message includes both the startup log and the post-listeners drain for diagnosis.

Fixed

  • fix(tests): restore hardening self-check snapshot for admin_health: src/homeserver/runtime.cpp populates runtime.hardening with run_startup_hardening_self_check() before marking the runtime started, so the health endpoint reports hardening:ok in unit tests. The final startup gate in src/main.cpp still overwrites the snapshot after all platform controls are applied.
  • fix(tests): smoke test expects the new readiness summary format: tests/smoke/meson.build no longer greps for the removed alpha_ready field; it checks for Hardening readiness: ready=false blockers=N in --dry-run output.
  • chore(tests): remove temporary diagnostic script: scripts/tmp_startup_test.sh is deleted.

Added tests

  • test(platform): rseq/membarrier/getcpu/futex_waitv numeric values are always present: new WHEN block in test_seccomp_hardening.cpp unconditionally checks the numeric syscall values (334/324/309/449 on x86_64; 293/283/168/449 on aarch64) are in the allowlist, regardless of what __NR_* macros the build headers define — mirroring the existing clone3/close_range/faccessat2 numeric assertions added in v0.10.6.
  • test(platform): hardening self-check no longer tolerates alpha exceptions: updated test_hardening_self_check.cpp to assert the absence of alpha_exception, to verify is_ready() fails closed when runtime controls are not applied, and to check that platform-specific sandbox controls map to enabled (not applicable) or unknown (not yet applied) instead of alpha_exception.
  • test(platform): seccomp allowlist allows ThreadSanitizer personality syscall: new SCENARIO in tests/unit/test_seccomp_hardening.cpp asserts that personality (135) is in the allowlist so the TSan federation worker is not killed with SIGSYS during startup.

0.10.7

Added

  • feat(platform): implement OpenBSD pledge(2) + unveil(2) sandbox: apply_runtime_hardening_controls() now calls unveil() for every path in the BSD hardening profile (read-only paths get "rx", writable paths get "rwc", /etc/ssl gets "r" for the LibreSSL CA bundle), locks the vnode allowlist with unveil(NULL, NULL), then applies pledge("stdio rpath wpath cpath flock inet unix dns proc exec", NULL). The promise set includes proc exec for the thumbnail and federation worker child processes and unix for the AF_UNIX IPC channel. openbsd_pledge_is_active() and bsd_pledge_promises() are now exported from runtime_hardening.hpp for testing. Tested on the Tier 1 OpenBSD CI job (vmactions/openbsd-vm@v1).
  • feat(platform): implement FreeBSD Capsicum cap_enter(2) capability mode: apply_freebsd_capsicum_capability_mode() is a new public API that enters Capsicum capability mode. It is called from run_server() in main.cpp after all resources are open (listeners bound, TLS loaded, federation worker spawned via posix_spawn() by path). Before calling cap_enter, the thumbnail worker binary is pre-opened with O_RDONLY | O_EXEC | O_CLOEXEC and the fd is stored in RuntimeMediaConfig::thumbnail_worker_fd. The child process in thumbnailer.cpp now calls fexecve(fd, argv, environ) on FreeBSD when the pre-opened fd is available, allowing thumbnail generation to continue after the global filesystem namespace is forbidden. freebsd_capsicum_is_active() probes cap_getmode(2) and is exported for testing. Tested on the Tier 1 FreeBSD CI job (vmactions/freebsd-vm@v1).

Fixed

  • fix(platform): FreeBSD thumbnailer.cpp build failure: move the environ declaration to namespace scope with extern "C" linkage in the FreeBSD-only fexecve() path. FreeBSD's <unistd.h> exposes environ only with C linkage, so the previous ::environ lookup failed with no member named 'environ' in the global namespace, and an earlier attempt to declare it at block scope was ill-formed. The global declaration preserves the existing behavior of passing the current environment to the thumbnail worker.
  • fix(platform): OpenBSD temp-directory failures on CI runners: introduce merovingian::tests::temporary_directory() with a fallback chain (std::filesystem::temp_directory_path() → /var/tmp → /tmp → /var/run → current path) and use it in all test scratch-file helpers and direct test callers. This makes the test suite robust on restricted CI images where /tmp is reported as not-a-directory. default_bsd_hardening_profile() keeps /tmp in filesystem.writable_paths so real OpenBSD deployments with a normal /tmp continue to have it unveiled.
  • test(platform): update BSD writable-path assertions after adding /tmp: tests/unit/test_runtime_hardening.cpp now expects three BSD writable paths (/var/lib/merovingian, /var/run/merovingian, /tmp) instead of two, matching the updated default profile.
  • chore(tests): gate NetBSD diagnostic prints to NetBSD only: the [netbsd-diag] std::cerr logging in tests/unit/test_federation_invite_join.cpp and tests/unit/test_otk_signature_validation.cpp now only emits on __NetBSD__; on Linux and other CI platforms the output is compiled away so it no longer looks like NetBSD-specific tests are running there.
  • fix(platform): FreeBSD Capsicum probe compilation error in runtime_hardening.cpp: auto mode = unsigned int{0U}; is rejected by the FreeBSD toolchain as an ill-formed braced functional cast; replaced with unsigned int mode = 0U; so the FreeBSD CI build and package jobs compile.
  • fix(ci): MESON_TEST_TIMEOUT_MULTIPLIER was ignored by the sanitizers workflow: scripts/build-linux.sh, scripts/build-bsd.sh, and scripts/build-wsl.sh now translate the environment variable into meson test --timeout-multiplier, so the ASan/UBSan and TSan integration-test runs get the intended 3× timeout instead of being killed at the 600 s default.
  • fix(ci): OpenBSD/BSD CI test runs permanently restricted themselves with pledge/unveil: start_runtime() now skips applying runtime hardening controls when MEROVINGIAN_TEST_DISABLE_HARDENING is set. The build scripts export this variable during meson test, and the PostgreSQL integration workflow sets it for its direct binary invocation, keeping the Catch2 test process unrestricted while production server binaries continue to apply hardening.
  • fix(platform): FreeBSD package build compile errors in main.cpp: add the missing #include "merovingian/platform/runtime_hardening.hpp" so apply_freebsd_capsicum_capability_mode() is visible, and replace the non-existent LOG_WARN macro with LOG_WARNING in the FreeBSD capability-mode entry block.

Changed

  • chore(platform): update BSD hardening profile rejection message: the fallback for non-OpenBSD/non-FreeBSD BSDs (e.g. NetBSD) now reads "BSD sandbox helpers are not yet implemented on this BSD variant" to distinguish from the old message that previously also covered OpenBSD and FreeBSD.

Added tests

  • test(platform): OpenBSD pledge probe returns false before pledge is applied: new #ifdef __OpenBSD__ SCENARIO in test_runtime_hardening.cpp asserts openbsd_pledge_is_active() returns false in the test runner (pledge not applied) — exercises the probe without permanently restricting the test process.
  • test(platform): OpenBSD pledge promise set covers all homeserver categories: new #ifdef __OpenBSD__ SCENARIO asserts all ten promise categories (stdio, rpath, wpath, cpath, flock, inet, unix, dns, proc, exec) are present in bsd_pledge_promises() and the string is space-separated.
  • test(platform): FreeBSD Capsicum probe returns false before capability mode entry: new #ifdef __FreeBSD__ SCENARIO asserts freebsd_capsicum_is_active() returns false (cap_enter not called).
  • test(platform): FreeBSD apply_runtime_hardening_controls accepts BSD profile without cap_enter: new #ifdef __FreeBSD__ SCENARIO calls apply_runtime_hardening_controls on FreeBSD (safe — returns accepted without calling cap_enter) and asserts accepted + probe still false.
  • test(platform): BSD runtime hardening scenario is now platform-conditional: the old scenario "BSD runtime hardening controls remain documented alpha exceptions" is replaced with a platform-aware equivalent: profile evaluation always accepts; apply_runtime_hardening_controls is tested on FreeBSD (accepts, Capsicum deferred) and non-OpenBSD/non-FreeBSD (rejects with unimplemented message); skipped on OpenBSD to avoid permanently pledging the test process.
  • test(platform): self-check BSD scenario comment updated: the "BSD sandbox controls are alpha_exception" scenario in test_hardening_self_check.cpp is updated to explain the correct reason (startup self-check runs before apply_runtime_hardening_controls, so probes return false at check time).

0.10.6

Fixed

  • fix(platform): SIGSYS crash on Fedora / modern Linux at startup: the seccomp-BPF allowlist conditionally included clone3 (435), close_range (436), and faccessat2 (439) only when the build-time kernel headers defined the corresponding __NR_* macros. Binaries compiled against Ubuntu 20.04 linux-libc-dev (kernel headers 5.4) therefore omitted all three, because close_range and faccessat2 were added in 5.9 and 5.8 respectively. On Fedora 36+ and other hosts with glibc 2.34+, the first pthread_create or posix_spawn call after seccomp was applied used clone3; without it in the filter the process was killed immediately with SECCOMP_RET_KILL_PROCESS (SIGSYS). All three syscalls now use a hardcoded numeric fallback for x86_64 and aarch64 when the macro is absent, ensuring they are always present in the deployed filter regardless of the build environment's kernel header version.

Added tests

  • test(platform): seccomp allowlist always contains clone3/close_range/faccessat2 on x86_64 and aarch64: new WHEN block in tests/unit/test_seccomp_hardening.cpp asserts that syscalls 435, 436, and 439 are present in the compiled filter by raw number, catching any future regression where a #ifdef __NR_* guard silently drops them from the allowlist.
  • test(platform): non-Linux seccomp probe returns not-probed and maps to unknown: #ifndef __linux__ SCENARIO in tests/unit/test_seccomp_hardening.cpp verifies that probe_seccomp_status() returns {probed: false, seccomp_active: false} on BSD (no /proc/self/status) and that seccomp_check_from_probe maps it to HardeningStatus::unknown — not alpha_exception or disabled.
  • test(platform): self-check pins BSD sandbox controls as always alpha_exception: new SCENARIO in tests/unit/test_hardening_self_check.cpp asserts pledge/unveil (index 7) and capsicum (index 8) are always alpha_exception, never enabled, since enabled_or_alpha_exception(false, ...) is hardcoded for both.
  • test(platform): self-check maps Linux-only controls to correct non-Linux statuses: #ifndef __linux__ SCENARIO in tests/unit/test_hardening_self_check.cpp asserts that seccomp (index 6) is unknown on non-Linux (not alpha_exception) and that core dump policy, no_new_privs, and capability bounding (indices 11–13) are alpha_exception on non-Linux, where the Linux kernel features are unavailable.
  • test(platform): BSD hardening plan is exhaustively tested — all six documentation fields: new SCENARIO in tests/unit/test_runtime_hardening.cpp individually sets each of unveil_documented, capsicum_documented, jail_documented, chroot_documented, and setrlimit_documented to false and asserts each independently causes evaluate_runtime_hardening_profile to reject with "bsd hardening plan is incomplete".
  • test(platform): BSD and Linux writable path sets use platform-appropriate mount points: new SCENARIO in tests/unit/test_runtime_hardening.cpp documents that both profiles share /var/lib/merovingian while Linux uses /run/merovingian and BSD uses /var/run/merovingian, and asserts the platform-specific socket directories do not cross-appear in the other profile.
  • test(platform): BSD profile rejects unsafe filesystem paths with the same rules as Linux: new SCENARIO in tests/unit/test_runtime_hardening.cpp asserts that protected (/etc/rc.conf.d, /usr/local/bin), non-normalized (/var/lib/../etc), and root-escape (/) paths are rejected on a BSD profile.
  • test(platform): BSD integrated flow — optional unavailable sandbox controls are accepted: new SCENARIO in tests/integration/test_runtime_hardening_flow.cpp evaluates a BSD profile in optional mode with optional unavailable pledge and capsicum gates and asserts both are accepted without blocking.
  • test(platform): BSD integrated flow — required unavailable sandbox gate fails closed: new SCENARIO in tests/integration/test_runtime_hardening_flow.cpp asserts that a required unavailable pledge gate fails closed with "required hardening gate unavailable: pledge", mirroring the Linux seccomp gate test.

0.10.5

Added

  • feat(dev): add installable pre-commit project gates: scripts/hooks/pre-commit now runs the unsafe C/C++ source gate, Catch2 BDD style checks, unit/conformance test registration checks, and a new staged-file changelog/docs guard. scripts/install-hooks.sh installs the tracked hook template into .git/hooks, and tooling tests cover the hook wiring and changelog/docs guard behavior.
  • feat(dev): add Codex post-edit clang-format hook: .codex/hooks.json wires a Codex PostToolUse hook for edit/write tool calls, backed by .codex/hooks/clang_format_after_edit.py, so edited C and C++ source files are formatted with clang-format -i immediately after Codex modifies them.

Fixed (PR review)

  • fix(federation): discovery timeout ignores configured remote_timeout: perform_sync_outbound_call used a hard-coded 30 s timeout for the discover_server() call regardless of the operator-configured security.federation.remote_timeout. Operators who raised the timeout for slow federation joins (e.g. 180 s) would still see discovery fail after 30 s during the .well-known fetch. Now passes timeout_seconds directly to discover_server().
  • fix(config): example security.federation.remote_timeout was 600s instead of the 60s default: config/merovingian.conf.example showed 600s, overriding the code default and tying up caller and worker slots for ten minutes per slow join. Corrected to 60s.
  • fix(scripts): check-staged-changelog-docs.sh missed deleted project files: --diff-filter=ACMR excluded D (Deleted) paths, so a commit that removes source or packaging files bypassed the changelog/docs guard. Added D to the filter.
  • fix(scripts): install-hooks.sh failed in linked worktrees: .git/hooks is not a directory in a linked worktree. Now resolves via git rev-parse --git-path hooks and mkdir -p.
  • fix(packaging): RPM %changelog entries missing for 0.10.5: Added 0.10.5 entries to packaging/rpm, packaging/rhel, and packaging/opensuse spec files.

Added tests

  • test(federation): outbound HTTP routing through worker IPC — coverage for WorkerPool::send_outbound_request, FederationProxy::send_outbound_request, and the outbound_http_request branch in WorkerEventLoop::run: tests/integration/test_federation_worker_flow.cpp gains four new scenarios: healthy-pool outbound dispatch to a quick-failing (ECONNREFUSED) pinned address across both shards; stopped-pool early-exit returning a non-empty error detail; and FederationProxy-level outbound dispatch proving the proxy delegates correctly to the pool.

Fixed

  • fix(federation): federation join/leave 502 failures logged at DEBUG instead of WARN: perform_sync_outbound_call and the three 502-path log_diagnostic calls for room.join.rejected (send_join timeout) and room.leave.rejected (make_leave/send_leave timeout) all defaulted to LogEventSeverity::debug. A timed-out join to a busy room produced no visible log entry at INFO level. The outbound failure path inside perform_sync_outbound_call and all three 502 rejection sites are now promoted to LogEventSeverity::warn per the log-level policy.
  • fix(federation): security.federation.remote_timeout config ignored for join/leave outbound calls: perform_sync_outbound_call constructed OutboundCall without setting connect_timeout_seconds or total_timeout_seconds, so both fell back to the hardcoded struct defaults (connect=10 s, total=60 s) regardless of what the config said. The function now accepts a timeout_seconds argument and sets both fields (connect capped at 30 s). All four callers — make_join, send_join, make_leave, and send_leave — now pass runtime.federation.config.remote_timeout_seconds. The config default for security.federation.remote_timeout is raised from "30s" to "60s" to match the old hardcoded value; operators joining large rooms (e.g. #twim:matrix.org) should set this to "180s" or higher.
  • feat(federation): join/leave outbound HTTP calls routed through the federation worker: perform_sync_outbound_call now signs the X-Matrix Authorization header in the main process (Ed25519 secret never crosses the IPC boundary) and then routes the resulting OutboundRequest through FederationProxy::send_outbound_request → WorkerPool::send_outbound_request → IPC outbound_http_request frame → worker thread pool. The worker executes the HTTP call via OutboundClient::perform and returns the result via IPC outbound_http_response frame. This prevents long-running federation joins (e.g. to busy rooms with 180 s timeouts) from blocking main-process HTTP handler threads. The IPC channel timeout is set to total_timeout_seconds + 10 s to give the worker time to return before the IPC side declares a timeout. The outbound_http_request handler is wired in WorkerEventLoop::run alongside the existing fed_request handler.

0.10.4

Changed

  • feat(federation): federation worker is now mandatory: the federation.worker.enabled and federation.worker.fallback_in_process configuration keys have been removed. The merovingian-fed-worker process is the only supported federation path; startup fails with a fatal error if the worker cannot be launched. FederationProxy is fail-closed with no in-process fallback. Config validation now always enforces non-zero values for federation.worker.shards, federation.worker.threads, and federation.worker.request_timeout_seconds.

Fixed

  • fix(federation): TOCTOU channel race in WorkerSupervisor: the supervisor restart loop could reset channel_ (a unique_ptr) while a request thread was between the healthy() check and the channel().send_request() call, causing a use-after-free. channel_ is now a shared_ptr protected by channel_mu_; external callers obtain a ref-counted snapshot via channel_snapshot() that remains valid across concurrent restarts.
  • fix(federation): room_id_from_send_body failed when nested JSON objects precede room_id: the old search stopped at the first } inside the first PDU object, which belongs to a nested content, hashes, or unsigned field rather than the PDU itself. The extractor now tracks brace depth (including string-escaped characters) to correctly identify the PDU closing brace, then searches within the full PDU span.
  • fix(federation): spurious sync wakeup after PDU ingestion: publish(next_stream_ordering) was called with the value of runtime_.database.next_stream_ordering after pdu_sink had already incremented it, waking sync clients with a token one step ahead of the event. The stream ordering is now captured before invoking pdu_sink.
  • fix(config): zero threads and zero request timeout were not validated: federation.worker.threads=0 and federation.worker.request_timeout_seconds=0 were only rejected when the worker was enabled. Since the worker is now always enabled, both values are always validated and rejected if zero.

Added tests

  • test(federation): BDD scenarios for nested-object PDU room ID extraction: tests/unit/test_federation_request_routing.cpp adds two new scenarios verifying that room_id is correctly extracted from /send bodies where content+hashes objects and deeply nested relates_to content precede the room_id field.
  • test(config): mandatory-worker validation scenarios: tests/unit/test_config_parser.cpp adds scenarios asserting that zero threads and zero request timeout are always rejected; removed the now-invalid "accepts zero shards when disabled" scenario.

CI

  • fix(ci): OpenSUSE Tumbleweed RPM build fails on cold cache: hendrikmuhs/ccache-action@v1.2 does not recognise the install parameter added in later releases and has no zypper support, so on a cold runner it attempts auto-installation and fails. The opensuse-rpm job now uses actions/cache@v4 to restore/save ~/.ccache directly and a shell step to wire ccache via CC=ccache clang / CXX=ccache clang++ in GITHUB_ENV, replacing the ccache-action entirely for this job. The earlier symlink + GITHUB_PATH approach was dropped because appending to GITHUB_PATH inside a Docker container step corrupts the PATH seen by subsequent steps, leaving sh unfindable at exec time.

0.10.3

Added

  • feat(federation): room-sharded federation workers (Phase 3): inbound federation requests are now routed across N independent merovingian-fed-worker processes using federation.worker.shards. Room-scoped endpoints are assigned by fnv1a_32(room_id) % shards; non-room endpoints (key queries, profile queries, etc.) route to shard 0. Each shard has its own WorkerSupervisor restart monitor and encrypted AF_UNIX IPC channel, so a CPU-heavy room can no longer starve federation traffic for all other rooms. FederationProxy extracts the room ID from request targets and from the first PDU in PUT /send/{txnId} bodies.

Changed

  • refactor(federation): WorkerPool owns N supervisors: FederationProxy now delegates to a WorkerPool that creates federation.worker.shards WorkerSupervisor instances. Each worker is launched with --shard <index> so log output can identify the shard; the per-worker IPC request handler captures the correct supervisor reference so pdu_ingest and sign_request responses are sent on the channel that received them.

Fixed

  • fix(federation): close the server-side IPC fd before duping the client fd in WorkerSupervisor: the posix_spawn file actions previously duplicated the child's socketpair end onto kWorkerIpcFd (3) and then closed the parent-side fd. When socketpair returned the parent side as fd 3, the close removed the newly placed IPC fd, causing the worker to fail with "ipc fd 3 is not open". The close now happens before the dup2 so the fixed IPC fd survives into the child.
  • fix(federation): WorkerSupervisor::healthy() reports healthy before start(): a constructed supervisor that has not yet spawned a worker is not failed; healthy() now returns true when no IPC channel exists yet, so pre-start health checks and unit assertions behave correctly.
  • fix(ipc): deserialize_fed_response parses numeric status field: the worker response frame carries status as a JSON number, but the deserializer was reading it with the string helper and defaulting to 500. A dedicated numeric parser now returns the actual HTTP status from the worker.
  • fix(packaging): include merovingian-fed-worker in all binary package manifests: the OpenSUSE, RHEL, Fedora RPM specs and the OpenBSD PLIST now list the new federation worker helper binary. Package builds that fail on unpackaged files will now succeed.
  • fix(build): include <charconv> in src/homeserver/worker_pool.cpp and avoid std::istreambuf_iterator in the federation worker: FreeBSD/Clang and RHEL/GCC builds failed because std::from_chars was used without the required header and because GCC 14 warned on the iterator-based file read. A chunked read loop and the missing include make the worker build cleanly on those compilers.
  • fix(ipc): close the IPC file descriptor only after the reader thread has joined: IpcChannel::stop() previously closed the descriptor and then joined the reader, so ThreadSanitizer saw a data race between FileDescriptor::reset() writing m_fd and FileDescriptor::get() reading it from the reader thread. The shutdown sequence now wakes pending waiters, calls ::shutdown() to unblock recv(), joins the reader, and only then closes the descriptor under write_mu_.
  • fix(ipc): IpcChannel::stop() reports the channel as unhealthy: healthy() continued to return true after an explicit stop() because the flag was only cleared on reader errors. stop() now clears healthy_ so callers can distinguish a deliberately stopped channel from a live one.

  • fix(federation): X-Matrix auth lost across IPC: serialize_fed_request omitted access_token, which carries the raw Authorization: X-Matrix … header value. Every federation request proxied to a worker arrived unauthenticated. The field is now serialized and restored on deserialization.

  • fix(platform): add execve/execveat to the seccomp allowlist: posix_spawn calls execveat in the child process, which inherits the seccomp filter. Both syscalls were absent from the BPF allowlist, so all worker spawns (initial and restart) were killed by the filter. The worker installs its own stricter filter after startup, so the main server's exposure is bounded.
  • fix(federation): graceful-shutdown watcher deadlock: after receiving a "shutdown" notification the IPC channel stays healthy, so the watcher loop ran forever and watcher.join() blocked indefinitely. The watcher now also checks the shutdown atomic and exits promptly.
  • fix(federation): healthy_ not restored after WorkerSupervisor restart: the supervisor set healthy_ to false when a worker died but never set it back after a successful respawn, permanently excluding that shard from routing. The flag is now reset to true on a successful restart. The false store is also moved to before channel_.reset() to close a TOCTOU window where a null channel_ pointer could be dereferenced by a concurrent request thread.
  • fix(federation): missing v2 shard-routing prefixes: /_matrix/federation/v2/invite, send_join, send_leave, make_knock, and send_knock were absent from the shard-routing prefix table, so all v2 membership requests landed on shard 0 regardless of the room they targeted.
  • fix(ipc): IPC frame cap raised from 4 MiB to 50 MiB: large federation transactions (many PDUs or large auth chains) could exceed 4 MiB, causing the frame to be silently dropped. The cap is now 50 MiB, matching common homeserver defaults.

Added tests

  • test(federation): BDD scenarios for federation worker shard routing: tests/unit/test_federation_proxy.cpp covers deterministic FNV-1a sharding, single-shard fallback to shard 0, non-room request routing, and the 503 fail-closed path when the selected shard's worker is unhealthy.
  • test(federation): exhaustive unit coverage for Phase 2/3 helpers: tests/unit/test_federation_request_routing.cpp, tests/unit/test_ipc_federation_frames.cpp, tests/unit/test_federation_worker_args.cpp, tests/unit/test_worker_supervisor.cpp, and tests/unit/test_worker_event_loop.cpp cover room-ID extraction from federation paths and /send bodies, IPC fed_request/fed_response frame round-trips, worker CLI argument parsing, and shard-index capture in supervisor/event-loop construction.
  • test(config): federation worker shard validation: tests/unit/test_config_parser.cpp asserts that federation.worker.shards is parsed, that shards=0 is rejected when the worker is enabled, and that shards=0 is accepted when the worker is disabled.
  • test(federation): end-to-end worker flow integration: tests/integration/test_federation_worker_flow.cpp spawns the real merovingian-fed-worker binary, waits for the pool to become healthy, routes non-room and room-scoped requests through the IPC channel, verifies shard selection, exercises in-process fallback when the pool is stopped, and routes room-scoped state requests and /send transaction bodies through the IPC channel.
  • test(ipc): expanded unit coverage for IPC framing and Ed25519 provider error paths: tests/unit/test_ipc_framing.cpp and tests/unit/test_ipc_federation_frames.cpp now cover control-character JSON escaping, invalid HTTP status normalization, empty-body request round-trips, skipped headers, concurrent request/response pairing, stopped-channel behavior, and IpcEd25519Provider error handling for null channels, unexpected response types, malformed signatures, and main-side signing errors.
  • test(federation): access_token IPC round-trip: test_ipc_federation_frames.cpp now asserts that access_token survives serialize_fed_request / deserialize_fed_request so X-Matrix auth is not silently dropped.
  • test(federation): v2 endpoint shard routing: test_federation_request_routing.cpp now covers all five /_matrix/federation/v2/ membership prefixes to verify the correct room ID is extracted for shard selection.

0.10.2

Added

  • feat(federation): sign-back channel for the out-of-process federation worker (Phase 2): the merovingian-fed-worker child no longer loads the Ed25519 server signing secret. When the worker needs to sign JSON (co-signing invites, /_matrix/key/... responses, etc.), it sends a sign_request IPC frame to the main process, which signs with its in-memory provider and returns the unpadded base64 signature. The private key never crosses the IPC boundary. New IpcEd25519Provider implements the crypto::Ed25519Provider interface over the encrypted channel; the main process handles sign_request in FederationProxy and returns sign_response.

Changed

  • refactor(runtime): centralise runtime signing provider: HomeserverRuntime now owns a crypto::Ed25519Provider* (crypto_provider) used by publish_server_signing_keys(), compose_signed_event(), invite co-signing, and outbound join/leave signing. find_active_server_signing_key() selects the active public key record without decrypting the secret. Worker startup injects IpcEd25519Provider via RuntimeStartOptions::signing_override; the main process uses the persisted production provider.

Added tests

  • test(ipc): BDD scenario for IpcEd25519Provider round-trip: tests/unit/test_ipc_framing.cpp covers a worker-side sign call being routed over the encrypted channel and returning the expected signature bytes.

0.10.1

Added

  • feat(federation): out-of-process federation worker (merovingian-fed-worker): federation CPU and I/O work (inbound PDU verification, state resolution, membership state machine, outbound dispatch) now runs in a dedicated child process with its own thread pool, so the main process threads are exclusively available for client-server API requests. A large room join no longer starves all connected clients. The IPC channel uses a socketpair(AF_UNIX) inherited file descriptor (no filesystem socket path), an ephemeral crypto_kx key exchange, and crypto_secretstream_xchacha20poly1305 encryption for all frames. Client access tokens are stripped before forwarding; the worker receives only the validated user MXID.

0.9.25

Fixed

  • fix(observability): eliminate duplicate fields in structured log lines: all 42 modules were using LOG_DEBUG(diagnostic_log_summary(...)), which produced lines with the level word appearing twice (<DEBUG> ... debug module event=...) and the real module name hidden behind the wrapper function name log_diagnostic:. The public log_diagnostic free-function in logger.hpp now calls the appropriate named method (debug(), info(), etc.) using the logger name as the module, so the line format is <LEVEL> <module>: event=<event> key=value ... with no redundancy. All 42 modules migrated to use observability::log_diagnostic() directly.
  • fix(observability): missing timestamp on auth diagnostic lines: auth_service.cpp was the only module calling the public observability::log_diagnostic() directly, which passed a raw summary string to SingleLog::log() without going through make_log_line(). The same fix above routes all calls through the named methods, restoring the timestamp header on every log line.

Changed

  • feat(observability): promote major lifecycle events to INFO level: the following events are now logged at INFO rather than DEBUG so operators see them without enabling verbose logging: login.accepted, logout.accepted, logout_all.accepted, registration.accepted (auth); room.create.accepted, room.join.accepted, room.join.accepted_remote, room.leave.accepted (rooms); signing_key.loaded, signing_key.generated, signing_key.rotated (crypto); migration.step.applied, migration.plan.complete (database); tls.context.ready, start.listeners_ready, start.database_ready, start.hardening_controls, start.complete, database.hydrated (server startup — already INFO, now correctly formatted). The database.state.repaired warning is also correctly emitted at WARNING via the structured path rather than the old LOG_WARNING(diagnostic_log_summary(...)) pattern.

0.9.24

Fixed

  • fix(client-server): re-acquire homeserver mutex after alias directory lookup in POST /join/{roomIdOrAlias}: when joining a room by alias, the handler unlocked the homeserver guard before the outbound federation directory query (/_matrix/federation/v1/query/directory) but never re-locked it afterwards. Any subsequent call into call_local invoked guard.unlock() on an already-unowned std::unique_lock, throwing std::system_error(EPERM). The exception was swallowed by the thread-pool worker (action=swallowed, type=St12system_error, what=Operation not permitted), silently aborting the join. The fix adds a single guard.lock() immediately after perform_sync_outbound_call returns, before processing the response body. Joining by room ID (which bypasses the alias lookup path) was unaffected.

Added

  • test(client-server): BDD regression coverage for POST /join via room alias guard unlock bug: tests/unit/test_client_server.cpp adds a scenario asserting that joining via a #alias:server form succeeds without throwing and returns the expected room ID.

0.9.23

Fixed

  • fix(federation): start the outbound dispatch worker eagerly at server startup instead of lazily on the first join request: DispatchWorker::start() was called inside wire_federation_callbacks_impl(), which was itself wired lazily on the first client-server request that needed federation. On Fedora/RHEL deployments with TasksMax= set, pthread_create returned EPERM and the resulting std::system_error propagated through the thread-pool worker, killing the worker and leaving federation permanently broken for that request. The fix has three parts: (1) DispatchWorker::start() now resets the started_ flag on exception so a retry is possible; (2) wire_federation_callbacks_impl() catches std::system_error from start(), logs the failure, and resets the worker instead of propagating; (3) main.cpp calls wire_federation_callbacks() eagerly immediately after the runtime reaches its final stable location so thread-creation failures surface at startup as a clear log entry rather than inside a request handler.
  • fix(rooms): auto-write m.direct account data when POST /createRoom is called with is_direct:true: when a DM room was created from one device (e.g. Element), the Matrix spec requires the client to subsequently PUT the m.direct global account data mapping the invitee to the new room. If that PUT never arrived — because the client manages it locally, or the server dropped it — a second device (e.g. ElementX) would see no m.direct entry and its is_dm:true sliding-sync list would return empty, causing it to create a duplicate DM room instead of showing the existing one. create_room() now calls a new upsert_m_direct() helper immediately after emitting the invite events: it reads any pre-existing m.direct mapping, appends the new room_id under each invitee's key (without duplicating), serialises, and persists via database::store_account_data(). Rooms created without is_direct:true are unaffected.

Added

  • test(rooms): BDD coverage for m.direct upsert on DM room creation: tests/unit/test_homeserver_room_service.cpp gains three scenarios — m.direct written when absent, second room appended without duplication, and non-DM room creation leaves m.direct untouched.

0.9.22

Fixed

  • fix(client-server): implement POST /_matrix/client/v3/pushers/set so Element X stops showing "route not found" when registering for push notifications: the endpoint was unimplemented and returned 404 M_UNRECOGNIZED, causing Element X to report that it could not receive notifications. The handler now validates the request body per Matrix v1.18 (required app_id, pushkey, and kind; additional app_display_name, data, device_display_name, and lang for non-null kinds; HTTPS /_matrix/push/v1/notify URL for HTTP pushers) and returns 200 {}. Merovingian still does not deliver push notifications, but clients can now register without error.

Added

  • test(client-server): add BDD coverage for POST /_matrix/client/v3/pushers/set: tests/unit/test_client_server.cpp exercises valid HTTP pusher registration, pusher deletion (kind:null), missing required fields, non-HTTPS URL rejection, missing notify-path rejection, and unauthenticated rejection. tests/conformance/test_client_server_conformance.cpp asserts the spec-required 200 {} response shape for both registration and deletion.

0.9.21

Fixed

  • fix(sync): emit explicit m.typing stop events so typing notifications can restart after a user stops typing: previously /sync only emitted an m.typing ephemeral event when the current list of typing users was non-empty. A stop-typing request removed the user from the list but sent nothing, so clients that replace their typing knowledge only on received events still believed the user was typing; the next start-typing event therefore appeared unchanged and clients did not surface a new notification. The server now tracks a per-room room_typing_stream_id cursor that advances whenever the set of typing users changes, and /sync (and the MSC4186 typing extension) emits the current list for that room on every change, including an empty user_ids array when the user stops. This also fixes transitions where one of several typing users stops but another continues, because the response now carries the full current list rather than only users whose individual cursor is newer than since.

Added

  • test(sync): typing stop/restart regression coverage: tests/integration/test_client_server_flow.cpp adds SCENARIO("Typing notifications can stop and restart via ephemeral events in /sync"), which asserts start -> non-empty list, stop -> empty list, restart -> non-empty list.

0.9.20

Fixed

  • fix(database): persist sync_stream_watermark so sync stream IDs cannot roll back across restart: sync_stream_id is the monotonically-increasing third component of the /sync stream token triplet and is used for to-device messages, device-list changes, presence, global account data, room account data, typing notifications, and read receipts. In-memory-only sync surfaces (rt.homeserver.typing_users and rt.homeserver.receipts) previously advanced the counter without persisting it, so a server restart could reset next_sync_stream_id to a value the client had already seen. This caused /sync long-polls to skip new ephemeral events and, in some clients, to return an empty rooms.join. A new sync_stream_watermark table stores the highest allocated ID, and database::allocate_sync_stream_id() atomically increments the in-memory counter and persists it before returning the ID. Fresh installs bootstrap schema version 1 and apply migration 002_sync_stream_watermark.sql; existing version-1 deployments migrate cleanly to version 2.
  • fix(sync): ensure ephemeral typing and receipt events advance the persistent sync stream counter: the PUT /typing/{userId} endpoint, receipt handling, and account-data/account-data paths previously advanced rt.database.next_sync_stream_id directly, which was both un-persisted and leaked counter-management details across modules. They now call database::allocate_sync_stream_id(store) so every typing notification, read receipt, and account-data write bumps the durable watermark, keeping the ordering stable across restarts.
  • fix(sync): deliver typing notifications to /sync recipients after homeserver restart: because the sync stream counter could roll back after restart, a typing event written after restart could carry an ID less than or equal to the recipient's since token. The /sync response builder therefore ignored it and the recipient saw no m.typing ephemeral event. With the watermark in place, allocated IDs are always strictly greater than any ID previously returned to clients, so typing notifications are delivered correctly after restart.
  • fix(database): seed the v2 migration watermark from the highest persisted sync_stream_id: migration 002 previously created sync_stream_watermark with the default value '0', so an existing database whose in-memory counter had advanced via typing/receipts would start the counter from the max of the persisted surfaces after restart. The migration now inserts COALESCE(MAX(CAST(stream_id AS INTEGER)), 0) across account_data, room_account_data, to_device_messages, device_list_changes, and presence_state as the initial watermark.
  • fix(sync): advance the sync stream counter when a client's since-token is ahead of the server: if the /sync since token's sync_stream_id exceeds store.next_sync_stream_id, sync_json() now calls database::ensure_sync_stream_id_ahead_of() to advance the counter (and persist the watermark) to the client's position. This recovers live deployments whose counter rolled back below a client's stored token — the next typing notification or receipt gets an ID strictly greater than the token and is delivered.

Added

  • test(database): add regression coverage for sync stream watermark persistence: tests/unit/test_database_persistence.cpp adds SCENARIO("Sync stream watermark prevents sync-stream id rollback across SQLite restart"), which writes a watermark via allocate_sync_stream_id(), re-opens the SQLite persistent store, and asserts the restored counter is greater than the pre-restart value.
  • test(sync): add typing notification delivery regression test: tests/integration/test_client_server_flow.cpp adds SCENARIO("Typing notifications are delivered via ephemeral events in /sync"), which creates a room, starts typing, waits for the recipient's /sync to return the m.typing ephemeral event, and asserts it contains the sender.
  • test(database): add regression coverage for counter rollback recovery: tests/unit/test_database_persistence.cpp adds SCENARIO("ensure_sync_stream_id_ahead_of recovers from a counter rollback"), which simulates a client since-token ahead of the persisted watermark, advances the counter, and asserts the next allocated ID exceeds the token.

0.9.19

Fixed

  • fix(sync): stop ElementX sliding-sync loop caused by repeated room re-inclusion: MSC4186 /_matrix/client/unstable/org.matrix.msc4186/sync now tracks the last stream ordering at which each room was returned per connection (SlidingSyncConnectionState::rooms_seen is a std::unordered_map<std::string, std::uint64_t>). The per-room delta floor is max(request_since, per_room_last_inclusion), so a room is not pulled back into rooms{} by unread counts alone. Unread counts are now sent only when the room is already included because its timeline or required_state changed.
  • fix(sync): wake MSC4186 sliding sync long-poll on typing and read receipts in joined rooms: the spurious-wakeup suppression logic only considered device-list changes, to-device messages, and account-data as relevant sync_stream_id advances. Typing notifications and read receipts in the user's joined rooms advanced the stream id without waking the long-poll, so ElementX did not receive live updates or conversation changes. The relevance check now also scans rt.homeserver.receipts and rt.homeserver.typing_users scoped to joined rooms. Added rooms_in_response, rooms_skipped, and rooms_window fields to the sliding_sync.response diagnostic log, plus BDD unit coverage for both the repeated-poll suppression case and the typing wake-up case.

0.9.18

Added

  • feat(client-server): implement Matrix room tag endpoints: GET/PUT/DELETE /_matrix/client/v3/user/{userId}/rooms/{roomId}/tags[/{tag}] is now wired for joined members, storing tags as per-user/per-room m.tag room account data. PUT accepts an empty body or { "order": <double> }; DELETE removes a tag; GET returns the spec-shaped { "tags": { "<tag>": { ... } } } map.
  • feat(canonicaljson): add general JSON parser/serializer with double support: canonicaljson::parse_json() parses arbitrary Matrix client JSON (using yyjson) into a new canonicaljson::Value::double_ alternative, and the serializer emits doubles with shortest round-trippable output. This lets room account data such as m.tag contain non-integer values without breaking the signing-focused canonical JSON pipeline, which still rejects non-integer numbers.

Fixed

  • ci: fix coverage and sanitizer jobs on the new branch: gcovr now runs with --gcov-ignore-parse-errors so clang-generated branch-heavy .gcov output for client_server.cpp no longer aborts coverage report generation. The ASan/UBSan job now sets MESON_TEST_TIMEOUT_MULTIPLIER=3 so the seccomp/SQLite integration test is not killed by the default 600 s timeout under sanitizer-induced slowdown.

0.9.17

Added

  • feat(homeserver): implement Matrix space hierarchy endpoints: GET /_matrix/client/v1/rooms/{roomId}/hierarchy now returns a paginated, depth-first list of rooms in a space tree, honouring max_depth, suggested_only, and limit, with URL-safe base64 pagination tokens. GET /_matrix/federation/v1/hierarchy/{roomId} is also wired so remote servers can fetch a space summary. Both endpoints use the local persistent state (m.space.child, m.room.create, m.room.join_rules) and apply visibility rules before exposing rooms.
  • feat(homeserver): implement Matrix v1.18 event relations endpoints: GET /_matrix/client/v1/rooms/{roomId}/relations/{eventId}[/{relType}[/{eventType}]] is now wired for joined members. The endpoint scans local room events for m.relates_to references to the parent event, optionally filters by rel_type and child event_type, supports dir, from, to, limit, and recurse query parameters, and returns a spec-shaped paginated chunk with optional next_batch, prev_batch, and recursion_depth fields. Encrypted poll responses that relate via m.reference are now returned as m.room.encrypted events, fixing Element polls that previously failed with 404 M_UNRECOGNIZED.

Fixed

  • fix(media): encrypted-room attachments no longer quarantined on upload and authenticated POST /_matrix/client/v1/media/upload is wired: E2EE clients (Element/Web) upload encrypted attachments as opaque application/octet-stream ciphertext, which was missing from the default security.media.allowed_mime_types allow-list and caused uploads to be quarantined; downloads of those quarantined files later returned 451 Unavailable For Legal Reasons. The default allow-list now includes application/octet-stream, and operators can override the list via the new security.media.allowed_mime_types configuration key. The authenticated v1 upload endpoint was also absent from the client-server dispatcher and has been added, using the same raw-binary-to-pipe-format translation as the unauthenticated v3 path.
  • fix(config): example configuration now includes all recognised keys: config/merovingian.conf.example was missing security.access_token_lifetime_ms, security.refresh_token_lifetime_ms, security.media.allowed_mime_types, security.media.remote_fetch_enabled, and security.secrets.master_key_file. Each is now documented with its default and, where relevant, the security implications of changing it.

0.9.16

Fixed

  • fix(media): media download and thumbnail endpoints no longer 404 on query parameters and now return raw bytes with Content-Type: GET /_matrix/media/v3/download/{serverName}/{mediaId}, GET /_matrix/client/v1/media/download/{serverName}/{mediaId}, and the equivalent thumbnail routes failed when clients appended ?allow_redirect=true (or ?width=...&height=...) because local_media_download_parts parsed the query string as part of the media ID. The helper now strips the query component before splitting server_name/media_id. Additionally, successful download/thumbnail results were returned in the internal content_type|bytes pipe format to clients instead of the raw bytes the Matrix spec requires; client_server.cpp now splits that payload and emits a Content-Type header.

0.9.15

Added

  • test(events): add BDD coverage for event_signer.cpp — new tests/unit/test_event_signer.cpp exercises signing_key_id_is_valid (valid, empty server_name, empty key_id, control chars, space, DEL), matrix_base64_from_bytes/matrix_bytes_from_base64 (round-trip, empty input, invalid base64), make_event_signing_payload without and with RoomVersionPolicy (success and non-object error paths), attach_event_signature (success, invalid key ID, short-decoded signature, non-object event), sign_event_for_server (success, non-object event, no key for server), verify_event_signature_presence (invalid key ID, non-object, missing signatures, missing server), and verify_event_signature (end-to-end success, provider-failure path).
  • test(crypto): expand signing_service.cpp coverage — six new scenarios appended to tests/unit/test_crypto.cpp: signing_key_record_is_usable directly tested for active, inactive, empty server_name, non-Ed25519 key_id, and wrong-size public key; sign_for_server with empty server name ("server name is empty"), key store error propagation, server-name mismatch ("active signing key server mismatch"), provider error propagation, and provider returning invalid signature shape ("provider returned invalid Ed25519 signature shape").
  • test(media): expand media/security.cpp coverage — eleven new scenarios appended to tests/unit/test_media_security.cpp: media_disposition_name for all three enum values, media_mime_type_is_allowed direct call with allowed and unlisted types, evaluate_media_upload for zero size limit, zero byte_size, missing content sniff result, reject (not quarantine) for unknown MIME with quarantine_unknown_mime=false, and reject (not quarantine) for scanner failure with quarantine_scanner_failures=false; remote_media_fetch_policy for invalid origin server and empty resolved_host, and SSRF-blocking-disabled path; admin_quarantine_policy for invalid admin user ID and invalid media ID; evaluate_decoder_safety for input bytes exceeding the limit and pixel count exceeding the limit.
  • test(auth): expand key_api.cpp coverage — five new scenarios appended to tests/unit/test_key_api.cpp: key_payload_is_loggable("") and redacted_key_payload_summary("") for the empty-payload branch; key_api_endpoint_name exhaustive coverage for all 19 enum values confirming no "unknown" fallback; match_key_api_route no-match cases (wrong path, wrong method); full GET and DELETE backup-route matching for version-by-id, room-key batch, room-key-by-session, room-key-by-room, and delete-version; key_api_database_statements for update, delete, get backup-version, claim-keys, and upload-signatures endpoints.
  • test(database): add BDD coverage for schema identifier quoting, core-table introspection, migration-step validation, and migration-plan validation error paths: tests/unit/test_database_schema.cpp exercises quote_sqlite_identifier rejection of empty inputs and SQL-injection characters (semicolons, embedded quotes, spaces), schema_table_is_core and schema_table_definition for known vs unknown table names, create_table_sql DDL generation and non-core rejection, current_schema_version non-zero invariant, initial_schema_tables non-empty invariant, migration_step_is_valid rejecting version-zero upgrade steps, hyphenated names, empty statement lists, and multi-statement SQL, migration_direction_name round-trips, migration_rollback_policy non-empty policy string, migration_plan_between same-version no-op and beyond-catalog empty-plan cases, apply_migration_plan state-version-mismatch failure, and migration_plan_is_valid covering no-op-with-steps rejected, upgrade-with-no-steps rejected, direction-mismatch rejected, and catalog-derived plan accepted.
  • test(homeserver): add BDD coverage for authentication and HTTP-dispatch failure paths: tests/unit/test_homeserver_error_paths.cpp exercises login with wrong password, login for unknown user, duplicate username registration, logout with unknown/empty token, verify_local_user_password with wrong password, handle_local_http_request returning 4xx for unrecognised routes, handle_local_http_request returning 401 for auth-required routes with no access token, and handle_federation_http_request returning 4xx for non-federation paths.
  • test(homeserver): add BDD coverage for auth_service functions not previously tested: tests/unit/test_homeserver_auth_service.cpp exercises bootstrap_admin_user success and duplicate rejection, admin privilege confirmed via authenticated_admin_user and denied for regular users, account_state_for_user returning nullopt for unknown/empty user IDs and AccountState::active for new registrations, logout_all_local_user rejecting unknown/empty tokens and revoking all sessions, change_local_user_password rejecting unknown tokens and verifying old token invalidation + old password rejection + new password acceptance, delete_local_device rejecting unknown user and unknown device and confirming session invalidation on valid deletion, issue_refresh_token_for_session failing for unknown users, refresh_local_session rejecting empty/unknown tokens, issuing new tokens on valid exchange, and enforcing single-use, and access_token_is_soft_logout returning false for empty and unknown tokens.
  • test(homeserver): add BDD coverage for room_service operations not previously tested: tests/unit/test_homeserver_room_service.cpp exercises ban_user rejecting unauthenticated callers, non-existent rooms, and non-privileged members; confirming the room creator can ban a member; kick_user rejecting unauthenticated callers and non-privileged members; confirming the room creator can kick; unban_user rejecting unauthenticated callers and confirming the room creator can lift a ban; forget_room rejecting unauthenticated callers, rejecting forget-while-joined, and succeeding after leave; knock_room rejecting unauthenticated callers and non-existent rooms.
  • test(federation): add BDD coverage for server-name validation boundaries and PDU envelope parsing error paths: tests/unit/test_federation_server_name.cpp exercises server_name_is_valid accepting valid domains and domain-with-port, rejecting empty strings, dot-free single-label names, names exceeding 255 characters, names with embedded spaces, newlines, and tabs; federation_discovery_policy rejecting unresolved hosts, empty address sets, invalid server names, and TLS-not-required remotes; parse_inbound_pdu_envelope returning nullopt for empty input, JSON arrays, JSON string literals, and malformed JSON.

0.9.14

Fixed

  • fix(client-server): implement GET /_matrix/client/v3/rooms/{roomId}/initialSync for room previews and stop Element Web "route not found" errors: this endpoint is deprecated for general syncing but is still used by Element Web to preview public rooms before joining. The route was previously unimplemented, returning 404 M_UNRECOGNIZED and causing an uncaught promise rejection when selecting a room from the directory. The handler now returns the spec-shaped RoomInfo response (room_id, membership, messages, state, visibility, account_data) for current and previous members, and allows non-member peeking when the room's m.room.history_visibility state is world_readable. Non-peekable or non-resident rooms return 403 M_FORBIDDEN so the client can fall back to joining; the spec only defines 200/403 for this endpoint.
  • fix(media): media uploads larger than 1 MiB no longer rejected at HTTP parse layer with CORS-less 413: parse_request_head checked Content-Length against a hard-coded 1 MiB default before the request reached handle_client_server_request, so any upload between 1 MiB and the configured max_upload_size (default 100 MiB) was refused at the transport layer with a 413 response that carried no Access-Control-Allow-Origin header — causing browsers to misreport it as a CORS error. The body-size check is removed from the parser (syntax validation only) and moved into serve_stream, where the target URL is available: POST /_matrix/media/v3/upload and POST /_matrix/client/v1/media/upload now use the configured max_upload_size cap; all other routes use the smaller general cap. When the cap is exceeded the 413 response now includes Access-Control-Allow-Origin derived from the request Origin header and the configured CORS policy, so browsers display the real 413 status rather than a spurious CORS error.

Added

  • test(client-server): add BDD coverage for GET /_matrix/client/v3/rooms/{roomId}/initialSync: tests/unit/test_client_server.cpp now exercises the new route for the creating member, a non-member in a world_readable public room (peek allowed), a non-member in a private room (403 M_FORBIDDEN), an unknown/non-resident room (403 M_FORBIDDEN), and the ?limit= query parameter.
  • test(http): add BDD scenario verifying parser accepts large Content-Length without error: test_http_request.cpp now includes a scenario asserting that POST /_matrix/media/v3/upload with Content-Length: 2097152 (2 MiB) parses without error, confirming the parser no longer rejects bodies above 1 MiB.

0.9.13

Fixed

  • fix(tests): repair compile errors in 3PID test suite introduced in v0.9.13: five occurrences of R"("})"} in conformance tests were parsed as raw string "} plus a stray } due to C++ raw-string early termination (the empty delimiter closes at the first )" sequence); fixed to R"("})". Two WHEN/GIVEN blocks in the integration and unit tests had missing ; on string-concatenation declarations, causing }; on the next line to close the enclosing scope prematurely and make all subsequent variables undeclared; fixed by adding the missing semicolons and re-indenting action code inside the WHEN blocks. integer_member helper calls in conformance tests renamed to the correct int_member. Integration test assertion for bob_duplicate_add relaxed from == 400U to != 200U since UIA correctly rejects with 401 before the duplicate check is reached. Unit test assertion that the M_SESSION_NOT_VALIDATED error body contains the sid value removed — the Matrix spec does not require the sid to be echoed in error bodies.
  • fix(client-server): remote room aliases now resolve over federation for room-directory lookups and joins: GET /_matrix/client/v3/directory/room/{roomAlias} previously only searched the local alias table, so looking up #alias:remote.example.org returned a misleading local M_NOT_FOUND instead of querying the alias-owning homeserver via federation. POST /_matrix/client/v3/join/{roomIdOrAlias} had the same gap: when given a remote room alias it rewrote directly to /rooms/{roomId}/join, treating the alias text as though it were already a room ID and never resolving the remote alias to the room ID plus via servers required for the join. The client-server handler now federates /_matrix/federation/v1/query/directory for remote aliases, rewrites remote-alias joins to the resolved room ID with merged via servers, and includes joined-member server names in local alias responses.
  • fix(client-server): account 3PID management now works end-to-end: Merovingian now implements the client-account 3PID flow instead of stopping at the auth-gate workaround. POST /_matrix/client/v3/account/3pid/email/requestToken and .../msisdn/requestToken remain unauthenticated per Matrix v1.18, reject identifiers already in use with M_THREEPID_IN_USE, and issue validation sid values. Authenticated clients can now complete POST /_matrix/client/v3/account/3pid/add, POST /_matrix/client/v3/account/3pid, POST /_matrix/client/v3/account/3pid/bind, POST /_matrix/client/v3/account/3pid/unbind, and POST /_matrix/client/v3/account/3pid/delete, while GET /_matrix/client/v3/account/3pid returns the spec-shaped threepids array with added_at, address, medium, and validated_at. GET /_matrix/client/v3/capabilities now also advertises m.3pid_changes.
  • fix(media): media uploads now accept real HTTP clients (raw binary body + Content-Type header): POST /_matrix/media/v3/upload previously delegated the raw client request directly to the local media router, which expected an internal pipe-delimited body (declared_mime|sniffed_mime|scanner_clean|bytes). Real Matrix clients (Element X, Element Web) send a raw binary body with a Content-Type header, causing every real upload to fail with 400 upload body must be declared_mime|sniffed_mime|scanner_clean|bytes. The handler now extracts the Content-Type header from the request and constructs the correct pipe-delimited body before forwarding to the local router. Missing Content-Type defaults to application/octet-stream. The route match is also widened from an exact string check to handle the ?filename=... query parameter (e.g. /_matrix/media/v3/upload?filename=avatar.jpg), which previously fell through to the general body-size gate and returned 413 for any upload larger than 64 KiB. Media upload requests are now governed by the configured security.media.max_upload_size (default 100 MiB) instead of the general 64 KiB client-API body cap. Quarantined uploads (internal MIME policy, 202 from the local router) are mapped to 200 toward the client with the assigned content_uri, since quarantine is a server-internal moderation action not defined in the Matrix spec response contract.
  • fix(sync): sync pool no longer exhausted by zombie timeout=30000 connections from rapid SDK reconnects: when a sliding-sync client (e.g. matrix-rust-sdk) sends a new timeout=30000 long-poll every ~90 ms while abandoning the previous TCP connection, each parked sync-pool thread was blocked in wait_for_change for a full 5-second poll slice before detecting the dead socket — even though the peer had already closed. With 11 new connections per second and a 5-second hold time, the 32-thread sync pool became exhausted within ~3 seconds, causing subsequent long-polls to queue behind zombie tasks or fall through to the main pool, stalling all other traffic. The fix reduces the poll-interval from 5 s to 1 s and adds a non-blocking recv(MSG_PEEK | MSG_DONTWAIT) liveness check after each slice timeout: a return value of 0 (TCP FIN) or a connection error (not EAGAIN/EWOULDBLOCK) causes the thread to exit immediately and close the fd without building or logging a response. Steady-state thread consumption drops from ~55 to ~11 for this pattern.

Fixed

  • fix(sync): sliding sync no-pos poll returns delta after first sync, ending the matrix-rust-sdk tight-loop: matrix-rust-sdk sends a timeout=0 (no pos in URL) probe alongside every timeout=30000 long-poll to get an immediate snapshot. Before this fix the server used since_event_ordering=0 for every no-pos request — regardless of whether the connection had been used before — re-delivering all accumulated room history (≈17 KB per cycle) on every call. The SDK received the same pos each time, treated it as a full initial sync, and immediately re-polled, creating a tight loop at ~11 cycles per second. The fix moves the per-connection state lookup before the since computation in sliding_sync_json: when pos is absent but the connection already has state (rooms_seen non-empty), since_event_ordering / since_sync_stream_id fall back to the connection's last-known cursors instead of zero. First call for a fresh connection still uses since=0 so the SDK receives the complete initial room list; all subsequent no-pos polls return a small delta (rooms: {} when nothing changed), causing the SDK to honour the 30-second long-poll instead of looping.

Added

  • test(client-server): add BDD coverage for remote alias federation failures: two scenarios in tests/unit/test_client_server.cpp verify the repaired path. Remote room-directory lookups now fail as federation errors rather than false local alias misses, and alias-based joins now fail only after remote alias resolution is attempted instead of treating the alias itself as a room ID.
  • test(client-server): add 3PID unit, integration, and conformance coverage: unit and integration scenarios now cover the account 3PID lifecycle across request-token, UIA add, bind, list, unbind, delete, and duplicate-rejection paths. Conformance coverage now asserts the spec-shaped unauthenticated request-token behavior, UIA challenge semantics for POST /account/3pid/add, and the required GET /account/3pid response shape.
  • test(sync): add BDD unit tests for sync pool zombie-connection detection: three scenarios in tests/unit/test_sync_pool_liveness.cpp validate the recv(MSG_PEEK | MSG_DONTWAIT) liveness mechanism using Unix socket pairs — peer-closed detection (returns 0), live-connection non-detection (returns EAGAIN/EWOULDBLOCK), and abrupt-reset detection via SO_LINGER with l_linger=0.
  • test(sync): add BDD unit tests for sliding sync no-pos delta behavior: two scenarios in test_client_server.cpp verify the root-cause fix — that a second timeout=0 poll on the same conn_id with no intervening events returns rooms: {} and the same pos, and that a poll after a new message event returns an advanced pos and includes the new timeline event.

0.9.11

Added

  • test(database): add direct coverage for media, policy, and key-backup cleanup helpers: expand unit coverage for store_local_media / update_local_media_state / store_remote_media, direct delete_policy_rule behaviour, and scoped key_backup_sessions cleanup paths (delete_key_backup_session, delete_key_backup_room_sessions, delete_all_key_backup_sessions).

0.9.10

Fixed

  • fix(sync): MSC4186 sliding sync long-poll no longer returns early when only another user's device keys were uploaded: handle_key_upload fans out PersistentDeviceListChange rows to every co-member when a user uploads keys, and each write fires the global SyncNotifier. This woke the uploading user's own sliding sync immediately, causing matrix-rust-sdk to reset to a full timeout=0 initial sync and loop at ~5 cycles/second. The fix adds a relevance check in sliding_sync_json: when only sync_stream_id has advanced (no new room events), the handler inspects whether any new device-list-change, to-device-message, or account-data row is actually addressed to the waiting user. If not, it returns needs_wait with since_sync_stream_id advanced to the current counter, so the notifier must fire again before the next wakeup. All three wait paths in the HTTP server (dispatch_local_http_request, inline blocking, sync_pool lambda) are updated to loop with can_wait=true after each notifier fire, continuing if the handler re-parks.
  • fix(sync): incremental MSC4186 sliding sync no longer returns unchanged rooms, ending the Element X tight-poll loop: two complementary bugs caused matrix-rust-sdk to treat every incremental response as carrying new data and immediately re-poll with timeout=0. First, build_room_response included all required_state events unconditionally; it now filters to only events whose stream_ordering > since_event_ordering on incremental responses (initial syncs are unaffected). Second, sliding_sync_json included every windowed room in the rooms{} object regardless of whether anything changed; rooms are now gated by has_room_updates — a room appears in rooms{} only when it is being seen for the first time, has new timeline events, has changed required_state, or has non-zero notification/highlight counts. Together these ensure that when nothing has changed since the pos the incremental response carries rooms: {}, which causes the client to honour the 30-second long-poll timeout instead of looping.

Added

  • test(sync): add BDD test verifying sliding sync spurious-wakeup suppression: two scenarios in test_client_server.cpp cover the device-key-upload case: when alice uploads keys (creating DLCs only for co-members as observers), handle_client_server_request(can_wait=true) returns needs_wait with an advanced since_sync_stream_id; when bob uploads keys (creating a DLC with alice as observer), the same call returns complete with device_lists.changed populated.
  • test(sync): add BDD unit tests for build_room_response incremental filtering: six scenarios in tests/unit/test_sliding_sync_room_builder.cpp cover the initial-vs-incremental required_state filtering: all matching state included on initial sync; state predating the pos omitted on incremental; only post-pos state included when mixed; wildcard */* filter respects the same ordering gate; no-update responses produce empty required_state_json and timeline_json; new timeline events correctly populate timeline_json.

0.9.8

Added

  • test(client-server): add direct route coverage for joined_members and presence updates: new unit scenarios cover GET /_matrix/client/v3/rooms/{roomId}/joined_members for current-member authorization, joined-profile shaping, and left-member exclusion, plus PUT /_matrix/client/v3/presence/{userId}/status for explicit online presence, default-offline behavior, malformed-body rejection, forbidden cross-user updates, and /sync delivery of m.presence events.
  • test(database): add direct persistence-helper coverage for filters, profiles, client transaction ids, account-data upserts, sync-stream rows, and room aliases: new unit scenarios exercise store_filter/find_filter upserts and sensitive JSON handling, store_profile with targeted displayname/avatar updates and missing-user rejection, store_client_txn idempotency so the first stored response wins while room/type scoping still permits distinct records, store_account_data global-vs-room upsert behavior, record_device_list_change and upsert_presence stream-id behavior and validation, and store_room_alias lookup/duplicate/missing-room handling.

0.9.7

Fixed

  • fix(sync): route POST /_matrix/client/unstable/org.matrix.simplified_msc3575/sync to the MSC4186 handler (0.9.7): Element X (matrix-rust-sdk) probes for sliding sync via the simplified_msc3575 path, not the msc4186 path. The unstable_features flag org.matrix.simplified_msc3575 = true was already advertised in 0.9.6, but the endpoint itself returned 404, causing the client to enter a tight retry loop — hundreds of failed sliding-sync attempts per second with concurrent fallback v3/sync calls. The dispatcher now accepts both /_matrix/client/unstable/org.matrix.msc4186/sync and /_matrix/client/unstable/org.matrix.simplified_msc3575/sync, routing both to the same MSC4186 handler.

0.9.6

Fixed

  • fix(ci): make coverage reporting match the actual project surface: the Codecov/gcovr path now excludes the real process entrypoint (src/main.cpp) rather than the nonexistent src/homeserver/main.cpp, and the coverage workflow now filters headers to include/merovingian/ instead of every file staged under include/. This stops vendored/platform headers from diluting the reported percentage and aligns the uploaded report with the code we actually own.
  • fix(sync): advertise MSC4186 with the compatibility flag Element X actually probes: GET /_matrix/client/versions now exposes both unstable_features["org.matrix.msc4186"] = true and unstable_features["org.matrix.simplified_msc3575"] = true. Element X's upstream matrix-rust-sdk currently autodetects sliding sync via the org.matrix.simplified_msc3575 flag, so advertising only org.matrix.msc4186 caused the client to reject the homeserver before ever calling the sliding-sync endpoint.

Added

  • test(sync): add direct MSC4186 room-list and extension coverage: new unit scenarios exercise compute_room_list filtering/sorting/incremental SYNC behavior and build_extensions scoping for to_device, e2ee, account_data, receipts, and typing. A dedicated tooling test now guards the coverage workflow/config so future changes cannot silently widen the measured surface or exclude the wrong entrypoint again.

0.9.5

Fixed

  • fix(rooms): GET/POST /_matrix/client/v3/publicRooms?server=<remote> now proxies to the remote homeserver (spec §10.5.1): both endpoints previously ignored the server query parameter and returned an empty local room list, causing "no rooms found" when clients searched a remote server (e.g. grapheneos.org). The handlers now check the parameter: when it names a different server the request is forwarded to GET /_matrix/federation/v1/publicRooms (unfiltered) or POST /_matrix/federation/v1/publicRooms (when a filter.generic_search_term is present). When server is absent or equals the local server name the existing local-list path is used unchanged. Unauthenticated federation (outbound_client not configured) surfaces as 502 M_UNKNOWN rather than a silent empty list. The raw since pagination token is now preserved and forwarded to the remote server instead of being parsed as a local integer offset.

0.9.4

Added

  • feat(sync): MSC4186 Simplified Sliding Sync (unstable) — POST /_matrix/client/unstable/org.matrix.msc4186/sync: implements the full MSC4186 Simplified Sliding Sync proposal as an unstable extension. The endpoint is advertised via unstable_features["org.matrix.msc4186"] = true in /_matrix/client/versions. Features: named room lists with windowed ranges, sort criteria (by_recency, by_notification_count, by_name), list operations (SYNC / INVALIDATE / INSERT / DELETE / UPDATE), per-room state and timeline with required_state wildcards, explicit room subscriptions with independent parameters, and all five MSC4186 extensions (to_device, e2ee, account_data, receipts, typing). Long-polling reuses the dedicated sync thread pool with the same slice-and-deadline pattern as v3/sync. Per-connection state is keyed by user_id/device_id/conn_id and tracks previous list windows so subsequent requests return only incremental ops. Position tokens (pos) reuse the existing hex-encoded StreamToken triplet. Overlapping or inverted ranges are rejected with 400 M_BAD_JSON.
  • test(sync): MSC4186 sliding sync integration test suite (tests/integration/test_sliding_sync_flow.cpp): seven integration scenarios exercising the full HTTP handler end-to-end — advertisement in /_matrix/client/versions, initial SYNC op with initial:true, required_state wildcard filtering, timeline_limit enforcement, incremental sync without initial:true on already-seen rooms, to_device extension delivery, and e2ee extension key counts. Also adds unit tests (tests/unit/test_sliding_sync.cpp) covering the parser layer and MSC4186 conformance tests (tests/conformance/test_sliding_sync_conformance.cpp) for all MUST requirements verifiable at the parser boundary.

0.9.3

Fixed

  • fix(sync): server respects client-requested /sync timeout in full (spec §9.4): the /sync long-poll handler now waits for the full client-requested duration. Previously a hard 5 s cap in the sync-pool dispatch lambda overrode every client timeout, causing connections with ?timeout=30000 to fire after 5 s and re-poll needlessly. The sync pool now polls in 5 s slices internally (so shutdown remains bounded) but each slice counts against the client's actual deadline. Clients that omit timeout continue to receive an immediate response per the spec.
  • fix(log): promote major auth and server lifecycle events from DEBUG to INFO: login.accepted, start.complete, start.database_ready, start.listeners_ready, and start.hardening_controls are now logged at INFO level. Previously every structured diagnostic was emitted at DEBUG, making logs uninformative unless debug mode was explicitly enabled. ERROR and WARNING events (login rejections, audit failures) were already routed correctly; this change covers the success-path events that operators need to confirm normal operation.

0.9.2

Fixed

  • fix(auth): access tokens no longer silently expire for clients that did not opt into refresh tokens (spec §5.6.2): login_local_user now accepts a with_ttl flag; the configured access_token_lifetime_ms is applied only when the client explicitly requests refresh-token support ("refresh_token": true in the login body). Clients that do not opt in receive a non-expiring access token, conforming to the Matrix spec requirement that servers SHOULD NOT expire access tokens without co-issuing a refresh token. Previously, every login silently set a 1-hour TTL regardless, causing users to be logged out every hour with no warning and no way to recover the session.

0.9.1

Fixed

  • fix(http): wire TLS sync pool to eliminate main-pool starvation and federated E2EE key-share delay: serve_tls_http now passes sync_pool and a TLS-aware write callback through to serve_stream. TLS long-poll /sync connections are offloaded to the dedicated 32-thread sync pool identically to the plain-HTTP path, freeing main pool threads for federation requests. Previously the sync_pool parameter was silently discarded (/*sync_pool*/), causing all TLS long-poll connections to block main pool threads for up to 30 seconds. This starved federation transaction processing, delaying delivery of m.direct_to_device key-share EDUs and producing the observable symptom of taking up to 30 seconds to decrypt a newly-joined federated user's replies. TlsConnectionStream is updated to hold shared_ptr<TlsConnection> so the TLS state can be shared safely between the read phase (main pool thread) and the async write phase (sync pool thread). The sync wait cap remains 5 seconds, matching the plain-HTTP path.

0.9.0

Fixed

  • fix(auth): include soft_logout: true in 401 responses for expired access tokens (spec §5.7.2): when an access token is found-but-expired the server now returns { "errcode" : "M_UNKNOWN_TOKEN", "error" : "unauthenticated", "soft_logout" : true} so Matrix clients use their refresh token rather than performing a full session logout. Revoked tokens continue to return a plain 401 without soft_logout, preserving hard-logout semantics for explicit revocations.

Changed

  • chore(release): beta milestone — promote from pre-beta (0.8.x) to beta phase (0.9.0): version number advanced to 0.9.0 per the versioning scheme phase markers. No functional changes; this commit updates all version strings across meson.build, source files, packaging metadata, and build scripts.
  • docs: update README to reflect beta status: banner note and Project Status section updated from pre-beta/in-development language to beta; pre-beta changelog history archived to CHANGELOG-pre-beta.md.