Database persistence¶
This capability note describes the project-owned database persistence boundary, the SQLite runtime backend, the PostgreSQL/libpq backend, and remaining work for production operation.
The deployed configuration parser accepts only postgresql and sqlite.
PostgreSQL startup fails when its URI file is missing, unreadable, or empty; it
never substitutes a process-local store. DatabaseBackend::memory is an
explicit programmatic backend for tests only and is not a valid config-file
value. Test fixtures that do not exercise persistence select it through
tests/support/in_memory_database_config.hpp.
Included now¶
- Prepared statement representation.
- Bound parameter representation with sensitivity metadata.
- Statement-name validation.
- Conservative SQL-shape validation.
- Redacted bound-parameter summaries.
- Database executor interface.
- Validated execution helper that rejects invalid statements before they reach an executor.
- Migration step and migration plan models.
- Contiguous upgrade and explicit downgrade migration-plan validation; data-only
migration statements (
INSERT/UPDATE/DELETE) are accepted as no-op schema-state transitions, enabling backfill and corrective migrations should a later migration need one. - The schema is a single step, version
1(initial_schema):schema::current_schema_version()is1, andmigrations/holds only001_initial_schema.sql. Every pre-1.0 migration (versions 2 to 26 as of 0.12.46) was collapsed into it in 0.12.47 (ADR-0224), so v1 creates all 62 tables in their final shape, every column that an earlier migration used toALTERon now being part of the table'sCREATE, plus four indexes:event_edges_prev_event_id,idx_to_device_messages_recipient_age,idx_deferred_pdus_room_idandidx_media_pending_uploads_owner. Nothing is seeded or backfilled: the old state-transition backfill and state-group seeding only ever mattered for upgrading existing databases and no longer exist. The compiled catalog insrc/database/migration.cpphas exactly one upgrade step (version1,initial_schema) and one downgrade step (to version0,drop_initial_schema, which drops every table).migrations/001_initial_schema.sqlis the frozen record of v1; a unit test requires it to match the build's v1 step statement for statement. - Schema change policy. Until
v1.0.0there is nothing to upgrade from: a database whoseschema_migrationsledger holds an upgrade row this build does not have (for example the rows 2 to 26 written by every build up to 0.12.46) is refused at open, before any migration or hydration (schema_ledger_is_known), and must be recreated. Fromv1.0.0the v1 migration andmigrations/001_initial_schema.sqlnever change; every schema change is a new numbered migration (v2 onwards) that keeps deployed databases compatible, and upgrade compatibility is promised from that release. - SQLite RAII wrappers around database connections and prepared statements.
- SQLite current-schema bootstrap for new database files.
- SQLite row hydration for users, devices, access tokens, rooms, memberships, refresh tokens, events (with depth), current state, server signing keys (with server_name), event DAG rows, event signatures, E2EE key state, media metadata, durable media blobs, remote media metadata, account data, policy rules, audit events, and admin actions.
- Write-through SQLite persistence behind the existing store mutation helpers.
- Transaction-aware persistent-store commits with SQLite rollback support.
- Atomic helpers for multi-row login, room creation, and state-event writes.
- Persistent helpers for refresh-token rotation, global/device access-token and refresh-token revocation, device display-name updates, and device deletion.
set_user_account_stateupdates theuserstablesuspended/lockedcolumns and mirrors the new state into the in-memoryPersistentUser, used by the admin lock/suspend endpoints.database::revoke_tokens_for_user_except_device(0.12.7) revokes every access and refresh token for a user except one named device, in a singleUPDATE ... WHERE user_id = $1 AND device_id <> $2per token table — so a password change withlogout_devices: truecan revoke every other device without the caller's own tokens ever being revoked, and therefore without anything to restore afterward. This replaces the prior revoke-then-restore pair:restore_tokens_for_device, which re-activated a device's tokens by settingrevoked = false, is removed from both the header and the implementation. ThatUPDATEcould not distinguish a token it had revoked microseconds earlier from one revoked days earlier by an unrelated logout or admin action, so it resurrected all of them — handing back a token the very password change was meant to invalidate. No function in the database layer may clear arevokedflag; revocation is a one-way transition. A requirement of the shape "revoke some subset" must be expressed as a narrower revoke, never as a broad revoke followed by a restore. See ADR-0052.- Access and refresh token rows carry an
expires_atcolumn (TEXT NOT NULL DEFAULT '', empty = no expiry / legacy) folded into the version-1 initial schema.store_access_token,store_refresh_token, andstore_device_and_access_tokenbind the epoch-millis text of the token'sexpires_at, and SQLite/PostgreSQL hydration parse it back into thePersistentAccessToken/PersistentRefreshTokenexpires_atfield (std::optional<system_clock::time_point>,nulloptfor empty). The runtime enforces expiry at the session/refresh lookup rather than at the store layer. - Token-hash equality at the store layer uses constant-time comparison
(
crypto::constant_time_equal, backed bysodium_memcmp) for the access and refresh token lookups, so a database-equivalent match does not branch on the fixed-length hash bytes. PersistentStore::server_signing_keyshas its own lock,server_signing_keys_mutex(0.12.13). The remote-key resolver stores and reads keys from federation relay threads and from backfill with the runtime mutex released, so after start-up hydration the vector is touched only throughstore_server_signing_key,find_server_signing_keyandsnapshot_server_signing_keys(a copy for callers that iterate). The lock is never held across the database write or a network call.- Media bytes are not hydrated (ADR-0119). The SQLite and PostgreSQL loaders read
media_blobswithout itsbytescolumn;prepare_media_blob_read(under the runtime mutex) andread_media_blob(without it) fetch one blob's bytes per request. A media upload writes its bytes incommit_local_media_upload; a deduplicated upload writes onlyref_count(update_media_blob_ref_count). Remote media is admitted bycommit_remote_media_admission, which in one transaction deletes the media andremote_mediarows of the cache entries the admission displaces, sets those blobs' reference counts (clearing bytes at zero), and inserts the new media, blob andremote_mediarows.remote_mediacarrieslocal_media_idandfetched_at_ms. room_aliasescarriescreator_user_id(CSAZ-12, ADR-0127):store_room_aliaswrites the creating user, anddelete_room_aliasremoves a mapping, in the database and in memory, forDELETE /directory/room/{roomAlias}.- Binary payload columns are
BLOB(#448):media_blobs.bytesholds raw media content andserver_signing_keys.secret_keyholds encrypted key material (BLOB NOT NULL DEFAULT '', empty = no secret persisted). Both were folded into the v1 schema. SQLite column reads are length-based (sqlite3_column_bytes), so payloads containing NUL or invalid UTF-8 round-trip byte-exactly.SchemaTableDefinition::columns_sqlis shared, backend-agnostic DDL text written in SQLite's dialect; PostgreSQL has noBLOBtype, sopostgresql_store.cpp'stranslate_ddl_types_for_postgresql()rewritesBLOBtoBYTEA(whole-word, so it cannot mangle an identifier) wherever aSchemaTableDefinitionbecomes DDL for that backend — both the bootstrap path and any later migration step. - Every SQLite connection pins
PRAGMA synchronous = FULLandPRAGMA journal_mode = DELETEat open (#447), so an environment that installs global default pragmas cannot silently weaken durability. - SQLite hydration fails closed when a row query cannot be prepared or stepped to completion.
- SQLite connections use a non-zero busy timeout for short-lived lock contention.
libpqdependency review and a PostgreSQL RAII connection/result wrapper.- PostgreSQL current-schema bootstrap, row hydration, and write-through transaction execution when a URI file is explicitly configured.
- Durable persistent-store helpers for device keys, one-time keys, fallback keys, cross-signing keys, key signatures, key backup versions, and key backup sessions.
- One fallback key per algorithm per device:
store_fallback_keydeletes the device's other fallback keys of the same algorithm and inserts the new one in a single transaction (spec v1.19,POST /keys/upload,fallback_keys). key_signatures_for_targetreads key signatures through an in-memory index keyed by target user (PersistentStore::key_signature_target_index). The index is maintained bystore_key_signature, rebuilt after SQLite and PostgreSQL hydration, copied with the store, and ignored (lookup scans) while its row count differs fromkey_signatures, so rows pushed directly never produce a missing result. Read signatures through this function, not by scanning the vector.- Physical migration-file loading for SQL files with explicit metadata and
statement names; the checked-in
migrations/directory contains only001_initial_schema.sql, the frozen record of the v1 schema. sync_stream_watermarktable stores the highest allocatedsync_stream_idand is updated bydatabase::allocate_sync_stream_id()before the ID is used for ephemeral events such as typing notifications and read receipts. This prevents the monotonic sync stream counter from rolling back across restarts.database::restore_sync_stream_id()writes the highestsync_stream_idfound in durable rows (account data, to-device messages, device-list changes, presence, read receipts) into the watermark on startup, so a restart starts from the maximum persisted value rather than the table default.device_list_changesholds at most one row per(observer_user_id, subject_user_id).database::record_device_list_change()andrecord_device_list_changes()replace a pair's earlier row (aDELETEthen anINSERTin one transaction) so it carries the newest stream position and the latestchange_type;/syncreports only the latest change per subject, so nothing is lost, and a peer sending the same EDU repeatedly cannot grow the table. The batch form validates every change first, allocates one sync stream position for all of them and commits once. This needs no migration: the primary key stays(stream_id, observer_user_id, subject_user_id). Rows written before this rule (duplicates per pair) are harmless, are collapsed bysync::collect_device_list_delta()on read, and are replaced the next time their pair changes.event_stream_watermarktable stores the highest allocated timelinestream_orderingand is updated byhomeserver::allocate_stream_ordering()(viadatabase::persist_event_stream_watermark()) on every allocation. Some allocations — membership stream positions — are not backed by a persisted event row, so a counter rebuilt frommax(events.stream_ordering)alone regresses across restarts, which puts clients' persisted sliding syncpos/sincetokens ahead of the live stream and invalidates them all on every restart. Hydration takes the maximum of the persisted watermark and the highest eventstream_ordering, then persists the merged floor so the row exists even on a first start.state_transitionstable records the previousevent_idfor every state-event tuple replacement. It is populated bydatabase::store_state()and the splitprepare_store_event_with_state()/apply_store_event_with_state()helpers. The in-memory store keeps a hash index overstate_transitionsso thatclient_event_with_id()can injectunsigned.replaces_stateinto the client event format in O(1) rather than scanning the whole vector. For federated state events the previous event ID is derived by walking the event graph (prev_eventsandauth_events) to find the predecessor state event of the same(room_id, event_type, state_key)tuple, instead of assuming the local arrival-time current state was the predecessor.account_threepidstable records the 3PID bindings a user has added to their account, replacing the prior in-memoryvector<AccountThreePid>which was lost on restart. Columns areuser_id,medium,address,country,id_server,added_at_ms,validated_at_ms, andbound, with a unique key on(medium, address)so the same email/msisdn cannot be bound twice.account_threepids.client_secret/account_threepids.sidcolumns store theclient_secret+sidpair the IS issued for a binding, so a later remote unbind can replay them in the request body (IS unbind auth mode 2). Both columns areTEXT NOT NULL DEFAULT ''; they are populated only for IS-bound 3PIDs and left empty for local-only bindings.PersistentThreePidBindingstruct (now carrying optionalclient_secretandsid) and thestore_account_threepid/find_account_threepid/delete_account_threepidstore functions (persistent_store.hpp) persist, look up, and remove bound 3PIDs. Implemented for both SQLite (sqlite_store.cpp) and PostgreSQL (postgresql_store.cpp); rows are hydrated into the in-memory store on backend open so existing bindings are visible immediately after restart.pusherstable records the push notification pushers a user has registered viaPOST /_matrix/client/v3/pushers/set. Columns areuser_id,app_id,pushkey,kind,app_display_name,device_display_name,profile_tag,lang,data_url,data_format(the pusher'sdatadictionary'surl/formatkeys, stored as plain columns rather than nested JSON), anddata_extra_json(a canonical-JSON-serialized object holding every OTHER member of the pusher'sdatadictionary at registration time; PR #479 review finding P1: the Push Gateway API's notify Device object is defined as "the data dictionary passed in at pusher creation minus the url key", so a custom member beyondurl/formatmust survive a restart and reach the gateway, not just be discarded), with a primary key on(user_id, app_id, pushkey)— the spec's uniqueness rule: setting a pusher with the sameapp_idandpushkeyfor the same user replaces it in place. ThePersistentPusherstruct and thestore_pusher/find_pusher/delete_pusher/list_pushers_for_userstore functions (persistent_store.hpp) persist, look up, list, and remove pushers, following the same pattern asPersistentThreePidBindingabove; implemented for both SQLite and PostgreSQL, hydrated on backend open.merovingian::push::PushGatewayClient(push_gateway_client.hpp) forwards a device'sdata_extramembers verbatim into the notify request body, and — since the PR #479 follow-up —client_server.cpp'sparse_pusher_set_body()captures everydatamember beyondurl/formatat registration time androom_service.cpp's pusher-to-PushGatewayDeviceconversion threads it through to a live delivery, so the gap noted above is closed end to end; seedocs/todos/capability-gaps.md.notificationstable records the historyGET /_matrix/client/v3/notificationsserves. Columns areuser_id,room_id,event_id,stream_ordering(the triggering event's global stream position — unique per row, since a recipient gets at most one notification per event, and doubles as this table's pagination key exactly likeevents.stream_orderingalready does forGET /messages),ts(the event'sorigin_server_ts, milliseconds),actions(the canonical-JSON actions array reconstructed from the matched push rule's resolved outcome —push_rules.hpp'sPushEvaluationResultdoes not keep the rule's raw actions array, soroom_service.cpp'spush_notification_actions_jsonrebuilds the conventional["notify", {"set_tweak": ...}, ...]shape),profile_tag(left empty: this recording happens once per(user, event)rather than once per pusher, so it cannot name one pusher's configured tag), andhighlight("true"/"false", mirroring the matched rule'sset_tweak: highlightaction, so?only=highlightcan filter without re-parsingactions), with a primary key on(user_id, event_id). ThePersistentNotificationstruct and thestore_notification/list_notifications_for_userstore functions (persistent_store.hpp) persist and list rows, following the same pattern asPersistentPusherabove; implemented for both SQLite and PostgreSQL, hydrated on backend open.room_service.cpp'sbuild_pending_push_deliveries()callsstore_notificationfor every local recipient whose push rule evaluation resolvesnotify: true— unconditionally, regardless ofserver.push.enabledor whether that recipient has a registered pusher:GET /notificationsreturns events the user "has been, or would have been, notified about" (spec), so a user with push turned off must still see their history. Only the Push Gateway delivery half of that function (building aPendingPushDeliveryand dispatching it) stays gated onpush.enabled+ a registered pusher. Thereadfield the endpoint reports is not stored on the row — it is computed at request time fromsync::read_receipt_markers()against the caller's currentm.read/m.read.privatereceipts in that room (unthreaded, or the one for the event's thread), so it always reflects the latest receipt without a write-side update on every receipt change. Retention:store_notificationprunes the oldest rows for thatuser_idbeyondserver.client_api.max_notifications_retained_per_user(default 1,000, valid range 1..100,000), after every insert. The same policy applies to the in-memory mirror and SQLite/PostgreSQL persistence; rows with the oldest stream ordering are removed first. It is independent ofserver.push.max_in_flight_deliveries, which bounds background tasks rather than persisted rows. Recording still occurs when push delivery is disabled. Increasing retention preserves more future history but cannot restore rows already pruned under an earlier policy. Changing the policy requires restart.openid_tokenstable stores the short-lived tokens minted byPOST /_matrix/client/v3/user/{userId}/openid/request_token(Matrix v1.19 CS API §OpenID) and redeemed byGET /_matrix/federation/v1/openid/userinfo(SS API §OpenID). Columns areuser_id,token_hash(primary key), andexpires_at(epoch milliseconds, encoded the same way asaccess_tokens.expires_at/refresh_tokens.expires_atviaexpires_at_text/parse_expires_at). Unlike an access token's expiry,expires_atis never optional here — every OpenID token has a finite lifetime. This is a separate table fromaccess_tokens, by design — seedocs/auth-identity.md("OpenID tokens") anddocs/threat-model.mdfor why that separation is the entire security property this feature depends on. ThePersistentOpenidTokenstruct and thestore_openid_tokenstore function (persistent_store.hpp) persist rows, following the same hashed-token pattern asPersistentAccessToken; implemented for both SQLite and PostgreSQL, hydrated on backend open.homeserver::request_openid_token(src/ homeserver/auth_service.cpp) mints and hashes the token — reusing the same keyed-hash machinery (issue_token_hash, preferring the master-key-derived v4 HMAC) access tokens use, since the hash algorithm is not the security boundary here — andhomeserver::federation_openid_userinfois the only function that ever reads this table back; the ordinary client-server auth gate (authenticated_user) never consults it, andfederation_openid_ userinfonever consultsaccess_tokens/sessions. Retention:store_openid_tokensweeps every already-expired row (across all users, not just the one being inserted) on each insert — appropriate because an OpenID token's natural bound is its own short expiry (one hour; seedocs/auth-identity.md) rather than a per-user row count, unlikenotifications' count-based cap above.login_tokenstable stores the short-lived, single-use tokens minted byhomeserver::complete_sso_loginwhen an SSO authentication completes (Matrix v1.19 CS API §"Client login via SSO") and redeemed exactly once byPOST /_matrix/client/v3/loginwithtype: m.login.token(homeserver::redeem_login_token). Columns areuser_id,token_hash(primary key),expires_at(epoch milliseconds, same encoding asopenid_tokens.expires_at), andused(TEXT NOT NULL DEFAULT 'false', following therevoked-column boolean-as-text conventionaccess_tokens/refresh_tokensalready use). This is a separate table fromaccess_tokens, for the same token-confusion reasonopenid_tokensis — seedocs/auth-identity.md("SSO login") anddocs/threat-model.md("Open redirect and login-token exfiltration via SSO redirectUrl").database::store_login_tokenpersists a row (reusingissue_token_hash, the same keyed-hash machinery access tokens use) and mirrors the openid-token retention strategy: every insert sweeps every already-expired-or-used row, not just the one just inserted, since a login token's natural bound is its own ~30s expiry or single use rather than a row count.database::consume_login_tokenis the only function that ever reads this table back and is the sole redemption path: it finds an unused, unexpired row matching one of the candidate hashes (constant-time compared viacrypto::constant_time_equal), marks it used in the backend before returning the owninguser_idso a persistence failure can never leave a token silently redeemable twice from memory alone, and returnsnullopton any miss (unknown, expired, or already-used token are indistinguishable to the caller). Implemented for both SQLite and PostgreSQL, hydrated on backend open.appservice_txn_cursortable persists the outboundPUT /_matrix/app/v1/transactions/{txnId}delivery cursor for each registered Application Service API (Matrix v1.19) appservice, keyed onappservice_id(one row per appservice). Columns:next_txn_id(the next fresh transaction id to allocate — monotonic, never reused),delivered_stream_ordering(high-water mark: every event up to and including this position has been acknowledged),pending_txn_idandpending_stream_ordering(the currently in-flight, unacknowledged batch, if any —pending_txn_id == 0means none). Every field is stored asTEXT, matching this table'spushers/notifications/openid_tokensneighbours, and parsed back tostd::uint64_tat hydration. ThePersistentAppserviceTxnCursorstruct and thefind_appservice_txn_cursor/store_appservice_txn_cursorstore functions (persistent_store.hpp) are implemented for both SQLite and PostgreSQL, hydrated on backend open. The cursor is the state of the one transaction in flight (pending_txn_id/pending_stream_ordering) plus the transaction-ID allocator; the queue behind it isappservice_outboxbelow. A retry of the in-flight transaction reuses the exact samepending_txn_idand re-derives the identical single event fromevents, never growing it, per the spec's "Homeservers MUST NOT alter ... events they were going to send within that transaction ID on retries."appservice_outboxtable (audit R01, ADR-0163) is the Application Service API transaction queue the spec requires: one row(appservice_id, stream_ordering, event_id)per event queued for one appservice, primary key(appservice_id, stream_ordering). A row is written when the event is dispatched (enqueue_appservice_event, idempotent on the key, interest decided at that moment) and deleted in the same backend transaction that clears the cursor's pending transaction and raisesdelivered_stream_ordering(complete_appservice_delivery), so a crash can never leave a delivered event queued or a queued event forgotten.stream_orderingisTEXTlike every numeric column in this schema; the in-memory mirrorPersistentStore::appservice_outboxis kept sorted numerically by(appservice_id, stream_ordering)(sort_appservice_outboxafter hydration).delivered_stream_orderingis now informational: it is not a threshold new events are compared against, because stream orderings are allocated before commit and events in other rooms commit out of order. Main process only: the table is not infederation_worker_table_allowlist. The queue of one appservice is capped atappservice.max_queued_eventsrows (default 100000, ADR-0191): at the capenqueue_appservice_deliverywrites no further row for that appservice, andcount_appservice_outbox_entries(two binary searches over the sorted mirror) is what it checks. No row is dropped for age.appservice_directorytable (ADR-0192) holds the rooms an appservice has published to the directory of one of its networks (protocols) withPUT /_matrix/client/v3/directory/list/appservice/{networkId}/{roomId}: one row(appservice_id, network_id, room_id), primary key all three.PersistentAppserviceDirectoryEntry,PersistentStore::appservice_directoryandset_appservice_directory_entry/appservice_directory_containsare implemented for both SQLite and PostgreSQL and hydrated on backend open; listing and unlisting are idempotent and commit before the mirror changes. It is independent ofrooms.directory_public. Main only: the table is not infederation_worker_table_allowlist.deferred_pdustable (R03, ADR-0165) holds the federation PDUsingest_pdu_eventcould not yet place because aprev_eventorauth_eventis unknown or aprev_eventhas no state group. Columns:event_id(primary key),room_id(indexed),origin(the server it arrived from, to backfill from),pdu_json(the envelope exactly as ingestion would have stored it, which is the redacted copy after a content-hash mismatch),received_at_ms(kept across attempts) andattempts(retries that did not place it). Every number isTEXT, like the neighbouring tables, and is parsed back at hydration.PersistentDeferredPduandupsert_deferred_pdu/remove_deferred_pdu/list_deferred_pdus/deferred_pdus_for_room(persistent_store.hpp) are implemented for both SQLite and PostgreSQL, hydrated on backend open. The store is bounded: 100 rows per room, 1000 in total, 16 MiB ofpdu_jsonin total (max_deferred_pdus_per_room,max_deferred_pdus_total,max_deferred_pdu_json_bytes); a new row that would exceed one is refused (DeferredPduUpsert::refused_full) while an update of a held row never is. Rows older than 24 hours (max_deferred_pdu_age) or retried 10 times (max_deferred_pdu_attempts) are dropped byhomeserver::retry_deferred_pdusand audited. The next-attempt time is not stored (HomeserverRuntime::deferred_pdu_next_attempt, in memory): after a restart every retained PDU is due at once. Main only: the table is not infederation_worker_table_allowlist, because the worker relays every PDU to main and never reads or writes it.receiptstable (ADR-0169) holds read receipts, local and federated: one row per(room_id, receipt_type, user_id, thread_id)(the primary key), namingevent_id, itstsand thestream_idit was last set at.thread_idis''for an unthreaded receipt,mainfor the main timeline, or a thread root's event ID. Every unread count is computed from these rows, so before this table a restart marked every room unread.PersistentReceipt,PersistentStore::receiptsandstore_receipt/remove_receipt(persistent_store.hpp) are implemented for both SQLite and PostgreSQL and hydrated on backend open;store_receiptallocates the sync stream position and commits the row before the mirror changes. At the inbound cap, it deletes the selected victim and upserts the incoming receipt in one transaction. A failed delete or upsert preserves the previous receipts in both the database and the mirror. The inbound receipt callback takesHomeserverRuntime::mutexbefore checking membership or reading/updating the receipt mirror, including on direct federation requests; worker relays can re-enter this recursive lock. Onlym.readandm.read.privateare stored here:m.fully_readis room account data. Inbound federated receipts are capped byhomeserver::max_inbound_receipt_entries; at the cap the oldest receipt of a remote user is removed, never a local user's. Main only: the table is not infederation_worker_table_allowlist.- The v1 schema's media tables carry the upload names and pending uploads.
media.upload_name(TEXT NOT NULL DEFAULT '', ADR-0211) is the upload'sfilename(or the remote server'sContent-Dispositionfilename), served back inContent-Disposition.media_pending_uploads(media_idprimary key,owner_user_id,created_at_ms,expires_at_ms, indexed by owner; ADR-0212) holds themxc://URIs reserved byPOST /_matrix/media/v1/createthat have no content yet.commit_pending_media_uploadinserts one and deletes the expired ones it was handed in the same transaction;commit_local_media_uploadwithcompletes_pending_media_iddeletes the row in the transaction that stores the content. Rows are bounded bymax_pending_uploads. Main only: not infederation_worker_table_allowlist(the federation media route runs in main).users.deactivatedcolumn (part of the v1 schema) is aTEXT NOT NULL DEFAULT 'false'column of theuserstable, set byPOST /_matrix/client/v3/account/deactivate(seedocs/auth-identity.md"Account deactivation"). It is deliberately distinct from the existing reversiblelocked/suspendedcolumns: a deactivated account can never log in again, and its row is retained (not deleted) so the localpart is never reissued to a new registration.set_user_deactivated(persistent_store.hpp) updates the column and mirrors the change into the in-memoryPersistentUser, following the same pattern asset_user_account_stateabove; implemented for both SQLite and PostgreSQL, hydrated on backend open. Adding a sixth column tousersalso exposed a latent risk instore_user's insert statement: it previously used a bareINSERT INTO users VALUES (...), which binds parameters by position and would have silently mismatched had the column order or count changed elsewhere.store_usernow names every column explicitly (INSERT INTO users (user_id, password_hash, locked, suspended, admin, deactivated) VALUES (...)), so a future schema change tousersfails loudly (wrong parameter count) rather than silently binding a value to the wrong column.- Phase A of spec-conformant PDU ingestion (ADR-0064): delta state groups,
forward extremities, and event status. This is storage plumbing only —
it does not change how PDUs are ingested, how local events pick
prev_events, or what clients see; that is later phases. The v1 schema holds: events.status—TEXT NOT NULL DEFAULT 'accepted', one of'accepted' | 'soft_failed' | 'rejected' | 'outlier'(spec: "Checks performed on receipt of a PDU").set_event_status/find_event_statusget/set it, validating the value and mirroring the change into the in-memoryPersistentEvent::statusfield (appended as that struct's last member, defaulting to"accepted", so no existing brace-init call site needed to change).state_groups, the root of a delta chain, withparent_state_group_id(TEXT NOT NULL DEFAULT '') anddelta_depth(TEXT NOT NULL DEFAULT '0'). Like every other nullable-in-spirit column in this schema (e.g.state_transitions.previous_event_id), the empty string is the "no parent, this is a snapshot" sentinel rather than a SQLNULL—PreparedStatement/BoundValuehas no NULL parameter representation, and this keeps the convention consistent with the rest of the schema. In C++,PersistentStateGroup::parent_state_group_idisstd::optional<std::string>(nullopt⇔ the empty-string sentinel).state_group_edgesis vestigial and unused — see "Federation worker least-privilege role" below for whether it should eventually be dropped.state_group_state (state_group_id, event_type, state_key, event_id)— one group's own rows: the full state for a snapshot group (parent_state_group_idempty), or just the changed entries for a delta group.event_state_groups (event_id PRIMARY KEY, state_group_id)— maps an event to the group holding the room's state immediately after it.forward_extremities (room_id, event_id)— a room's current DAG leaves.- Index:
event_edges_prev_event_id ON event_edges (prev_event_id).event_edges' primary key is(event_id, prev_event_id), which cannot serve a lookup keyed onprev_event_idalone; the ingest-time "children of this event" lookup (is this event anybody'sprev_event_id?) needs exactly that shape, and without the index it is a full table scan per probe. Confirmed byEXPLAIN QUERY PLANintests/integration/test_state_groups_flow.cpp's "event_edges has a usable index for prev_event_id lookups in the v1 schema" scenario.state_groupshas no matchingroom_idindex: every store function that reads it (find_state_group,read_state_group_full_state,create_or_reuse_state_group's parent lookup) looks up bystate_group_id, its primary key — nothing queries it byroom_id.
Store API (persistent_store.hpp):
create_or_reuse_state_group(store, room_id, new_state_group_id,
parent_state_group_id, full_state) builds the group that will hold
full_state — reusing parent_state_group_id unchanged when full_state
is exactly the parent's own state (read via read_state_group_full_state,
never re-derived by re-diffing the raw rows a second way), writing a full
snapshot when there is no parent, the parent's delta_depth + 1 would
exceed events::max_state_group_delta_depth (100), or full_state is
missing a key the parent has (deltas never encode deletions — state
resolution never drops a key present in any fork, so this should not
happen, but a missing parent key is handled safely rather than assumed
impossible), and otherwise writing only the changed entries as a delta.
Fails closed (returns nullopt) if the named parent does not resolve.
read_state_group_full_state(store, state_group_id) walks parent links
back to the snapshot and applies every delta on the chain, newest first;
bounded to max_state_group_delta_depth + 1 hops and cycle-detected — a
missing group, a cycle, or an over-long chain returns nullopt, never a
partial map. find_state_group returns a group's own row (not its
resulting state). update_forward_extremities(store, room_id, event_id,
prev_event_ids, accepted) removes prev_event_ids from and adds
event_id to the room's extremity set when accepted is true, and is a
no-op when false (soft-failed/rejected events never become extremities
and never remove one); find_forward_extremities reads the current set.
All four are implemented for SQLite, PostgreSQL, and the in-memory store's
write-through path (the PersistentStoreBackend::memory backend commits
trivially, so unit tests exercise the in-memory bookkeeping directly —
see tests/unit/test_state_groups.cpp).
A fresh database starts with no state groups or forward extremities; they are
written as rooms are created and events ingested. Nothing is seeded at
bootstrap. PostgreSQL bootstrap creating these tables is covered by
tests/integration/test_state_groups_flow.cpp.
- Phase B1 of spec-conformant PDU ingestion (0.12.13, ADR-0064): the phase
A tables are now written and read on the live ingest path, not just
declared in the schema. New module
merovingian::homeserver::state_bookkeeping(src/homeserver/state_bookkeeping.cpp): compute_state_before(store, room_id, policy, prev_event_ids)— the state immediately before an event: a singleprev_event's own after-state directly (find_event_state_group+read_state_group_full_state, no resolution needed); severalprev_eventsresolved viaevents::resolve_state_v2over their after-states. Fails closed (StateBeforeResult::ok = false) if anyprev_eventhas no recorded state group, or resolution itself does (ADR-0063's walk cap) — never silently substitutes current state.compute_state_after(state_before, event_id, event_type, state_key)—state_beforeplus the event itself when it is a state event.record_event_state(store, room_id, event_id, prev_event_ids, state_after)— creates or reuses the after-state group (chained offprev_event_ids.front()'s own group as the delta parent, so unchanged state reuses it percreate_or_reuse_state_group's own dedup), mapsevent_idto it, and updates forward extremities.recompute_current_state(store, room_id, policy)— current state as the resolution over the room's forward extremities (one extremity: its after-state directly; several:resolve_state_v2), diffed against the cachedcurrent_stateand written only where changed, throughdatabase::store_state— the same call every other state write already used, sostate_transitions(unsigned.replaces_state) and the existing sync wake-up path (SyncNotifier::publish, called by the ingest caller on an accepted result) stay correct with no separate "state diff" plumbing.current_stateis a cache of this result, never an independent write target, from this path onward.
Wired into ingest_pdu_event (src/homeserver/local_http_router.cpp):
state-before is computed before the event is persisted (a missing
prev-state fails the PDU closed with the new
federation::PduIngestionStatus::missing_prev_state — not a rejection;
the transaction it arrived in still returns 200 per spec, since a
delayed-but-legitimate PDU is indistinguishable from one whose history we
have not fetched yet. Since R03 (ADR-0165) the PDU is also retained in
deferred_pdus and the status is deferred; missing_prev_state is what
is left when nothing could be retained); the after-state group, forward-extremity update,
and current-state recomputation happen after the event is durably stored,
logged-but-non-fatal on failure (the same trade-off the pre-existing
membership-persistence step in the same function already makes for the
same reason: the raw event is already committed, so failing the PDU here
would only cause pointless retries).
Phase B1 completion (same 0.12.13 branch, follow-up commits): every
local and inbound-accepted event path now goes through this bookkeeping,
not just ingest_pdu_event.
- homeserver::store_local_event (state_bookkeeping.hpp/.cpp) is the
choke point every locally created event must go through — runs the same
compute-state-before / store / record-after-state / recompute sequence
as ingest_pdu_event, but every failure is a hard failure (the request
fails), since a local event has not yet been told "success" to anyone.
homeserver::forward_extremities_for_new_event gives local events their
prev_events from the room's real forward extremities (capped at the
spec's 20 per event, events::max_prev_events_per_event, highest depth
kept) instead of the previous, DAG-unaware "last event pushed into
store.events" heuristic. persist_composed_event (room_service.cpp
— used by create_room's initial-state chain, invite/join composition,
and every ordinary send/state-send/redaction) calls it instead of
database::store_event_with_state directly.
- local_http_router.cpp's membership_acceptor (a remote user's
send_join/send_leave/send_knock into a room we are resident in —
the same trust boundary as ingest_pdu_event) gets the identical
state-before check and after-state/current-state bookkeeping.
invite_handler's invite-event store gets explicit status =
"outlier" (it is never part of this server's own timeline).
- The federated-join flow (join_room/perform_federated_join,
room_service.cpp) now seeds a snapshot state group from the
send_join response's state (stored as status = "outlier" events by
ingest_send_join_state, which returns the full state map alongside
the joined-members list), gives the join event an after-state group
chained off that snapshot (homeserver::record_event_state_with_parent,
record_event_state generalised to an explicit parent group), and
makes it the room's sole forward extremity — so the first inbound PDU
after a join no longer hits missing_prev_state. Since FED-1
(ADR-0083) only entries whose room is the joined room are stored, and
auth_chain events are stored as outliers with no current_state row;
repair_missing_state_entries (start-up) likewise only promotes
accepted events, never an outlier or a rejected one.
- A source-tree guard test (tests/unit/test_store_event_choke_point.cpp)
fails the build if database::store_event_with_state( appears anywhere
in src/ outside a reviewed, counted allowlist.
- PduStateConflictContext / state_conflict_resolver (the older,
never-production-reached conflict-resolution plumbing this phase was
meant to replace) are still left in place unchanged and unused —
phase B2 removes them.
A real bug was found and fixed along the way: database::store_state's
state_transitions insert had no ON CONFLICT handling, but its primary
key is (room_id, event_type, state_key, event_id) — state resolution
making an event current again after it was already superseded once (the
spec's "reverts to an older event" case) re-inserts that same key, which
both backends reject as a duplicate row; the write is now an upsert
(ON CONFLICT (...) DO UPDATE SET previous_event_id = excluded.previous_event_id),
with the in-memory state_transition_index mirror updated to match.
Tests: tests/unit/test_state_bookkeeping.cpp,
tests/unit/test_pdu_ingestion_state_groups.cpp,
tests/unit/test_local_event_state_bookkeeping.cpp, and
tests/unit/test_store_event_choke_point.cpp (all
[pdu_ingestion][state_groups]) cover the module directly, end-to-end
through ingest_pdu_event, end-to-end through local send/create paths,
and the source-tree guard, respectively;
tests/integration/test_join_room_flow.cpp covers the federated-join
seeding end to end; tests/unit/test_sync_handler.cpp covers a
resolution-driven reactivation reaching an incremental /sync client.
- Token rotation lineage (ADR-0074). refresh_tokens.predecessor_hash and
access_tokens.predecessor_refresh_hash (both TEXT NOT NULL DEFAULT '') are
part of the v1 schema. A token pair minted by POST /refresh records the hash
of the refresh token it replaced, which stays valid until the pair is first
used (see docs/auth-identity.md). Both backends store and hydrate the
columns; database::revoke_refresh_tokens_with_predecessor supersedes an
unused pair when a refresh is retried. Hydration
(homeserver::hydrate_local_database) also copies each access token's
expires_at into its session; before 0.12.13 it did not, so expiry was not
enforced after a restart.
- M05 authenticated-media storage
(ADR-0068).
The media table's legacy_endpoint_visible column (TEXT NOT NULL DEFAULT
'true') is part of the v1 schema. New local uploads are stored with
legacy_endpoint_visible = false (see docs/media-repository.md); the
unauthenticated /_matrix/media/v3/download and /thumbnail routes treat a
false value as a 404, while authenticated /_matrix/client/v1/media/...
routes ignore it and serve the media. Adding a column to the media table
does not affect the federation worker table classification: media remains
in worker_never_reads_tables, so the worker allowlist needs no change.
- /sync calls database::ensure_sync_stream_id_ahead_of() when the client's
since token is ahead of the server's counter. This recovers live deployments
whose counter rolled back below a stored token (for example, when the watermark
table did not yet exist and ephemeral typing/receipt events advanced the
in-memory counter), ensuring the next ephemeral event gets an ID the client
will accept.
- Offline merovingian-db-migrate planning scaffold.
- Database runtime and migration role separation.
- Runtime hydration for users, sessions, rooms, memberships, events, client
device listings, and durable media repository blobs.
- Runtime event writes persist previous-event edges, auth-event edges, and
Matrix event signatures in the same transaction as the event row.
- Event depth column persisted alongside events so depth survives restarts.
- Server signing keys scoped by server_name with composite primary key.
- Runtime trust-and-safety report/review paths append durable policy audit rows
and admin action rows.
- Policy rule and media blob helpers upsert durable rows and hydrate them after
SQLite/PostgreSQL reopen.
- Committed statements are not retained (AUTH-11). commit_persistent_transaction
used to append every committed PreparedStatement, bound parameters included,
to PersistentStore::prepared_statements, which nothing trimmed. The store now
keeps nothing unless a test calls enable_statement_capture(store, capacity);
the capture is PersistentStore::captured_statements, holds at most
capacity statements (clamped to max_statement_capture_capacity, 65 536),
drops the oldest first, and is empty and disabled by default.
sensitive_values_are_redacted reads that capture and returns false when capture
is disabled instead of passing on an empty buffer. Production code never enables
capture: password and token hashes must not outlive the commit in process memory.
- PersistentStore::audit_log is a bounded window (AUTH-1): the newest
max_in_memory_audit_events (1 024) rows, oldest dropped first, with
audit_log_evicted counting what left. The table is the complete record.
Append through append_audit_event; hydrate through remember_audit_event.
Hydration keeps the newest rows in insertion order on both backends (PostgreSQL
orders by ctid, the only ordering the column-less audit_log offers).
load_audit_events_by_type_prefix(store, prefix, limit) reads older rows straight
from the table (newest first, limit clamped to max_audit_query_rows, prefix
bound and matched literally); in-memory stores answer from the window.
append_audit_event cuts actor, target and reason to 255 bytes on a UTF-8
boundary. See docs/observability-audit.md, "Audit volume bounds".
- Unit coverage for statement validation, executor gating, redaction, migration
planning, and schema inventory.
- Migration-plan validation coverage uses explicit hand-built plans, while
current-schema upgrade coverage separately tracks schema-version bumps.
- Integration coverage proving SQLite users, sessions, rooms, and events survive
a homeserver runtime restart.
- Upgrade coverage (tests/integration/test_database_upgrade_flow.cpp, ADR-0216,
ADR-0224). migrate_sqlite_schema_to and migrate_postgresql_schema_to build a
schema at an older version and stop there: they bootstrap an empty database,
apply migrations forward to exactly the target, and hydrate nothing. They refuse
a version this build does not know before touching the database, and a schema
already past the target, since migrations never run backwards. Today the
catalog has only v1, so the scenarios cover the v1 build-and-stop seam, never
going past this build, an unopenable file being refused, tables without a ledger
being refused (SQLite), a fresh database having every table's final columns and
all four indexes, and a pre-1.0 ledger (rows 2 to 26) being refused on both
backends. The "any older version opens at current" scenario is vacuous until a
v2 exists. There is no previous-release fixture: the 0.11.13 fixture was
removed when the migrations collapsed (0.12.47, ADR-0224). When the 1.1 series
starts, the last 1.0.x release writes the next fixture with
scripts/generate_upgrade_fixture.py (see docs/release-process.md).
- Both stores read the migration ledger in numeric order
(ORDER BY CAST(version AS INTEGER)): schema_migrations.version is TEXT, and
ordering by it alone put '10' before '9'.
- The PostgreSQL store hydrates profiles like the SQLite store. Before 0.12.42 it
wrote them but never read them back, so displaynames and avatars disappeared at
every restart.
- Live PostgreSQL integration coverage: a dedicated GitHub Actions workflow
(.github/workflows/postgres-integration.yml) starts a PostgreSQL 16
service, provisions a merovingian_migration role with DDL grants and a
merovingian_runtime role with table-level DML grants, and runs the
live integration scenarios at
tests/integration/test_postgresql_persistence_flow.cpp. Scenarios
assert: schema is bootstrapped to current_schema_version, previously
persisted rows survive a close/reopen, and a runtime-role session is
denied DDL.
- PostgreSQL role helpers (set_postgresql_role, reset_postgresql_role,
current_postgresql_user) in
postgresql_store.hpp
let runtime callers switch identities inside a single connection. Role
names are validated against PostgreSQL identifier shape (alphanumeric
plus underscore, ≤ 63 chars) before being interpolated into the
SET ROLE statement, so the API is safe to call with operator-supplied
role names.
- Bootstrap table-identifier validation (issue #442): PostgreSQL's
create_table_if_missing_sql previously concatenated table.name directly
into CREATE TABLE IF NOT EXISTS <name> (...) DDL with no validation or
quoting, unlike the SQLite path. It now mirrors sqlite_store.cpp exactly:
schema_table_is_core() gates the name to the compiled core-table set, and
quote_sqlite_identifier() (generic ANSI double-quote identifier quoting,
despite the name — valid for PostgreSQL too) wraps it. Current callers only
ever pass hardcoded core table names, so this is defense-in-depth against a
future caller passing a non-core or attacker-influenced name.
- Client transaction-idempotency dedup via the version-1
client_txn_idstable. Keyed on(user_id, room_id, event_type, txn_id);room_idis empty string for to-device sends.event_idstores the assigned event ID for room sends and is empty for to-device entries. Both SQLite and PostgreSQL hydrate rows on startup. Handlers check the table before processing and store the result after a successful send, allowing clients to safely retryPUT /rooms/{roomId}/sendandPUT /sendToDevicerequests. PUT /sendToDevicewrites its localto_device_messagesrows and itsclient_txn_idsrow in ONE backend transaction (database::enqueue_to_device_messages_with_client_txn, R02, ADR-0164). The per-recipient age and count evictions (CSAZ-10, ADR-0117) are part of that transaction:enqueue_to_device_messageand the batched function share one prepare step that builds the DELETE/INSERT statements without touching memory, and the in-memory mirror is updated only after the commit succeeds. A failed commit leaves the store unchanged and the transaction unrecorded, so the handler answers500 M_UNKNOWNand a retry with the same transaction ID is processed rather than replayed. Stream ids are allocated before the commit (allocate_sync_stream_idpersists the watermark itself); a failed commit skips them, it never reuses them. A request with no local recipient records the transaction alone through the same function.- An inbound
m.direct_to_deviceEDU writes all of itsto_device_messagesrows in ONE backend transaction (database::enqueue_to_device_messages, ADR-0166), through the same prepare/apply steps and evictions as the single-message and/sendToDevicewriters. A failed commit stores none of the EDU, and the sink records its(origin, sender, message_id)in the in-memory replay window only after the commit, so the origin's resend is delivered in full. database::reload_room(store, room_id)re-reads a single room's rows (room, membership, invites, events, state, and the event-relation tables scoped to that room's events) from the backing database and replaces this store's in-memory copy of that room. Implemented for both SQLite and PostgreSQL with parameterised, room_id-scoped queries (never a full-table re-read). A no-op (returnstrue) for thememorybackend. Used by the federation worker to refresh its otherwise-stalePersistentStoresnapshot — see architecture.md, "Federation worker room staleness".database::reconstruct_event_relations(store)re-derives everyPersistentEvent::prev_event_ids/auth_event_ids/signaturesfrom the flatevent_edges/event_auth/event_signaturestables. Those fields are only populated directly when an event is stored fresh within a process's own lifetime (store_event_with_state); hydrating a store from disk previously left them silently empty. Called at the end of bothopen_sqlite_persistent_store/open_postgresql_persistent_storeand as part ofreload_room's snapshot merge. Idempotent.database::store_event_with_state()is split intoprepare_store_event_with_state(),commit_persistent_transaction(), andapply_store_event_with_state(). ThePreparedStateUpdatestruct holds the event, optional state event, and the prepared statements.preparevalidates in-memory pre-conditions (room existence, duplicate event id) and builds the statements.commitexecutes them on the backend without holding any room lock, so different rooms can commit concurrently.applymirrors the committed rows into the in-memory vectors with an idempotent duplicate guard. Used by the per-room inbound PDU path so the global runtime lock is released before the database commit. The combined helper remains for callers that do not need the split.
Security posture¶
The homeserver runtime can now use SQLite for local persisted state when
database.backend=sqlite is configured. Dependency-specific SQLite types remain
inside the database module and do not leak into homeserver services.
The boundary provides these guarantees:
- Application code submits named prepared-statement shapes, not ad hoc SQL execution requests.
- Invalid statement names fail before reaching the executor.
- Obvious multi-statement/comment-shaped SQL is rejected at the boundary.
- Sensitive parameter values can be summarized without leaking the value.
- Migration plans are contiguous and direction-aware.
- Core table inventory is explicit and test-covered.
- Media rows include hash algorithm, digest, quarantine state, removal state, and durable blob references before runtime media writes are accepted.
- Fresh SQLite database files are created with the current schema and recorded migration metadata.
- Existing SQLite database files apply pending project-owned migrations before
runtime state is hydrated. A database whose
schema_migrationsledger holds an upgrade row this build does not know (for example the rows 2 to 26 written by pre-1.0 builds up to 0.12.46) is refused at open, before any migration or hydration (schema_ledger_is_known), on both backends. - Auth and room mutations fail the request when required persistent writes fail. Token revocations (logout, logout-all, refresh rotation and reuse, device deletion, password change) are checked against the store after the write, and a revocation that did not persist answers 500 (DB-5, ADR-0116).
- A media upload is answered with an
mxc://URI only after its media row and blob are committed together (commit_local_media_upload, one transaction). A failed commit answers 500 andmedia::rollback_local_media_uploadremoves the record and releases the blob reference in memory, so a restart cannot lose media a client was told was stored (DB-5). Media moderation commits flags, blob references and its audit row together (commit_local_media_moderation). - Device/token, refresh-token, room/membership, and event/current-state mutations are committed before in-memory runtime state is updated.
- Signed event DAG rows are committed before the runtime room timeline is updated.
- Multi-row runtime mutations commit through one backend transaction so partial login, room, or state-event writes are rolled back.
- PostgreSQL connection strings are accepted only in explicit URI or libpq key/value form. Connection-opening diagnostics (DB-9) use a fixed redacted identity and generic parser/connection-status refusal reasons rather than reconstructing a supplied option summary or forwarding library error text. Host/database values are suppressed too because they can contain sensitive material. The existing unit checks pass 6 / 1; new actual DEBUG-console coverage passes 85 / 2, including malformed quoted options and four admitted quoted/spaced/escaped/URI forms reaching a real libpq failure against an owned private nonexistent socket path. It checks required records, whole/partial marker absence, returned identity/reason and environment/stdout/owned-path restoration. Normal database integration passes 2628 / 58; these are first-GREEN coverage of the existing guard, not a new production repair. Bounded independent source review passed without security or logic findings; reviewed files match executed 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 completed-archive/current-source inspection. Native Windows socket execution, live authentication/TLS/ready branches and general SQL-result error confidentiality are not established by these checks. See ADR-0139.
- Remote PostgreSQL TLS admission (DB-8): every effective host and host-address
entry is checked. Unless all destinations are local Unix sockets or recognized
loopback literals, parsed connection options must explicitly select
sslmodewith the valueverify-full. An omitted or empty mode cannot inherit its way through admission viaPGSSLMODE; the minimal worker environment deliberately excludes that default. Service indirection and insecure remote opt-outs remain unsupported. The opener disables preferential GSS encryption and database-name expansion; private credential readers, role handling, binary bindings and transaction semantics are unchanged. Certificate trust and sandbox accessibility must be provisioned for each process. The new deterministic unit regression failed 4 / 28 assertions before repair and passes 28 / 1 afterward; all DB-8 checks pass 48 / 2 and database units pass 18363 / 174. Live TLS/hostname/certificate refusal and worker trust-file access are not proven by admission tests; independent source review passed without security or logic findings. Fresh combined acceptance passed 56 OK targets with all return codes zero, no failures/timeouts and 135 unchanged snapshotted files, including all focused sources; the source freeze ended after acceptance. Normal DB-6 integration passes 2733 / 15 and both isolated PostgreSQL modes pass 178 / 2 with verified teardown, fixture removal and current-source binding; these local compatibility checks do not establish remote TLS transport. See ADR-0138. - Runtime startup requires
database.role=runtime; offline migration planning requiresdatabase.role=migration. - No store function may clear a
revokedflag once set — revocation is a one-way transition, enforced by the store's API surface rather than by caller discipline (seerevoke_tokens_for_user_except_deviceabove and ADR-0052). - Binary (
BLOB) columns round-trip byte-exactly on both backends. The columns aremedia_blobs.bytesandserver_signing_keys.secret_key— everyBLOBinmigrations/*.sqlandschema.cpp; a new one must follow this mechanism.BoundValuecarries abinaryflag, and a write must set it for everyBLOBparameter. SQLite binds every parameter by explicit length and reads columns bysqlite3_column_bytes, so it ignores the flag and is unchanged. On PostgreSQL,execute_prepared_statementbinds abinaryparameter in libpq's binary wire format (paramFormats= 1, explicitparamLengths, declared typebytea), so arbitrary bytes — NULs, invalid UTF-8, backslashes — arrive exactly and nothing is escaped or truncated. The read side is one generic rule rather than a per-table special case:load_result_rowsasks libpq for each result column's type (PQftype) and decodes everybyteacolumn from PostgreSQL's\xhex text form back to raw bytes, so any query that selects aBLOBcolumn gets bytes, not hex. Each connection pinsSET bytea_output = 'hex'at open so a role or database default ofescapecannot change that form; abyteavalue that is not valid hex fails the query rather than returning empty or garbled bytes, because an emptysecret_keyreads as "no signing key" and would make the server mint a new identity. Until DB-1 was fixed onlymedia_blobs.byteswas decoded, so the signing secret came back as its own hex text after a restart and the server could not load its signing key (0.12.14 audit). Existing rows hold the raw bytes intact, so no data migration was needed. - Identity-server unbind credentials (
account_threepids.client_secretand.sid) are bound as sensitive values, so they never reach a query trace or diagnostic log. They remain plaintext at rest; that is the documented residual risk indocs/threat-model.md§IS-delegated bind/unbind/requestToken. - Migration plans are serialised across processes. Each step commits in its own
transaction, so the window that needs protecting is between the
transactions: PostgreSQL takes a
pg_advisory_lockfor the whole plan under an RAII lease (a crashed migrator's session-scoped lock disappears with its connection, so recovery needs no operator action), and SQLite re-reads theschema_migrationsledger inside each step'sBEGIN IMMEDIATEand skips a step the winner of a race already applied. These are deliberately not one shared mechanism — see ADR-0059.
Deliberately not included¶
The v1 push_rules schema table remains unused: a user's push rules are kept as
their m.push_rules global account data, applied over the current server defaults
(ADR-0204), because that table's primary key cannot express a rule's kind. Federation queue
tables (federation_destinations, federation_transactions) are hydrated and
used by the runtime on both backends; media metadata and policy rows are also
persisted and hydrated.
Next starting points¶
merovingian-db-migrate(src/db_migrate.cpp) remains an offline planning tool: it prints a migration plan and does not connect to a database. Runtime startup applies pending schema migrations automatically.- PostgreSQL role separation is enforced in the live server path. When roles
are configured,
open_postgresql_persistent_store()assumesdatabase.migration_rolefor DDL (the fresh-database v1 bootstrap and any later pending migration), resets it, and then assumesdatabase.runtime_rolefor runtime access. The fresh-database bootstrap fails closed if the migration role cannot be assumed, so the migration role owns every table on a fresh install; a single-role deployment (no migration role) bootstraps as the login role. Live role scenarios run in thepostgres-integrationworkflow because they require PostgreSQL and the role environment variables. Seepackaging/postgresql/provision-roles.sql. - The schema's v1
push_rulestable is unused: push rules are kept asm.push_rulesaccount data (ADR-0204, see above). The configuration keyserver.identity_server.allowed_bind_domainsis accepted but currently read by no bind orrequestTokenpath, so it is not enforced.
Federation worker least-privilege role (ADR-0062 part 2)¶
The out-of-process federation worker (merovingian-fed-worker,
src/federation_worker/) is the process most exposed to hostile input (see
src/federation_worker/AGENTS.md). Before 0.12.13 finding N1 part 2, it
opened the database with the same PostgreSQL login as main, so a
compromised worker could SELECT server_signing_keys.secret_key (still
ciphertext, but exactly what decrypts it once the operator master key is
recovered by an unrelated bug — see ADR-0062 part 1) and every other
credential-bearing table, even though no worker code path ever reads them.
Do not use peer authentication for main's role (ISO-8, ADR-0147). The
worker runs under main's uid, and Landlock does not govern connect() to a
Unix socket, so a compromised worker can reach PostgreSQL's local socket; with
peer (or ident) PostgreSQL would log it in as main's role on the uid alone,
undoing the separate login below. Use scram-sha-256 or certificate
authentication for both roles.
Two independent mechanisms close this, together:
-
A separate login, not
SET ROLE.SET ROLE-based separation was rejected for this boundary specifically: a session holding the login role that granted a restricted role can alwaysRESET ROLEback to it, so a compromised worker holding the original login credential gains nothing from a restricted role it can trivially undo. Only a distinct login credential the worker process never holds in the first place is a real boundary — see ADR-0062's "Options considered and rejected". The new config keyfederation.worker.database_uri_filenames a secret file holding this login's connection URI. Main reads and validates it exactly likedatabase.uri_file(owner-only, regular file, TOCTOU-safe —validate_existing_secret_file_metadatainsrc/main.cpp) and hands the bytes to each worker shard over a third inherited pipe fd (homeserver::kWorkerDbUriFd,--db-uri-fdon the child's argv) — the worker itself never opens this file.federation_worker:: apply_worker_database_urithen setsdatabase.worker_conninfo_overrideon the worker's own in-memoryConfigcopy and clearsuri_file,runtime_role, andmigration_role: the separate role connects directly with its own grants, never attempting aSET ROLEagainst roles it was never made a member of.packaging/postgresql/ provision-federation-worker-role.sqlprovisions the role; see that file's header comment for the full GRANT SQL and the reasoning behind each statement. It grantsSELECTonly — neverINSERT/UPDATE/DELETE— since the worker's ownPersistentStoreis a read-only snapshot for room-scoped federation reads and every write it needs is relayed to main over IPC (src/federation_worker/AGENTS.mdrule 3; the one exception found during review isstate_conflict_resolver, covered below). -
A load profile for PostgreSQL-backed workers.
database::TableLoadProfileanddatabase::table_load_profile_includes(include/merovingian/database/persistent_store.hpp) decide which tablesopen_postgresql_persistent_store's row loader hydrates.TableLoadProfile::federation_workeris an allowlist (database::federation_worker_table_allowlist), not a denylist: a table absent from it is excluded by default, so a future migration's new table — secret-bearing or not — is unreadable by the worker until someone adds it to the allowlist deliberately. The full allowlist, derived by tracing everyFederationRuntimeStatecallback the worker does NOT override to thePersistentStorefield it reads (see the array's own doc comment inpersistent_store.hppfor the file:line citation behind each row):
| Table | Why the worker needs it |
|---|---|
rooms |
make_join/make_leave/make_knock templates, query/directory, space hierarchy |
membership |
query/directory (servers joined to an aliased room), space hierarchy member counts |
current_state |
membership-template auth_events, room_version_resolver, room_server_acl_provider, space hierarchy join-rule/space checks |
events |
membership-template forward extremities, backfill, state, state_ids, get_missing_events |
event_edges |
reconstruct_event_relations populates PersistentEvent::prev_event_ids from this table — without it, every event's prev_events read back empty for the routes above |
event_auth |
same reconstruct_event_relations dependency, for auth_event_ids (auth-chain walks in state/state_ids) |
event_signatures |
same reconstruct_event_relations dependency, for PersistentEvent::signatures |
room_aliases |
query/directory (find_room_alias) |
server_signing_keys |
remote-key caching (remote_key_cache_probe/remote_key_resolver) — column-restricted, see below |
state_groups |
ADR-0064 phase A (0.12.13): the worker serves federation /state and /state_ids locally today from current_state/event relations; a later phase moves that to state groups, granted now so that phase needs no further role change |
state_group_state |
same ADR-0064 phase-A reasoning, for a group's own delta/snapshot rows |
event_state_groups |
same ADR-0064 phase-A reasoning, for the event → state group mapping |
forward_extremities |
same ADR-0064 phase-A reasoning: the worker's membership-template forward-extremity read (currently derived from events) becomes sourced from here in a later phase |
schema_migrations |
needed for ANY store to open (schema-version check via load_schema_state), not gated by TableLoadProfile at all |
server_signing_keys needs a genuine caveat: the worker's remote-key
cache legitimately reads and writes this table (caching other servers'
public keys — federation::find_cached_remote_key /
store_server_signing_key, src/homeserver/local_http_router.cpp:1857-
1871), so excluding the whole table would break that. What must never be
exposed is this server's own secret_key column. The fix is
column-level, in two independent places: load_persistent_rows
(src/database/postgresql_store.cpp) uses a worker-specific 4-column
SELECT that never names secret_key at all, leaving
PersistentServerSigningKey::secret_key empty for every worker-loaded
row; and provision-federation-worker-role.sql grants
SELECT (server_name, key_id, public_key, valid_until_ts) — a
PostgreSQL column-level grant — so even a bug that widened the C++ query
back to five columns would be refused at the database. The worker's
write attempt (caching a freshly-fetched key) is expected to fail under
this SELECT-only grant; the caller already discards that result
(std::ignore = cache_remote_server_keys(...),
src/federation/remote_key_cache.cpp) and simply re-fetches on the next
request, so the failure degrades performance, not correctness.
Every table PersistentStore can hold that is not in the allowlist is
read by no worker-local code path — verified table by table during review
and listed under "Every table classified" below. This code-path audit is
separate from what the backend hydrates: the profile gates PostgreSQL
hydration only. SQLite's open_sqlite_persistent_store() accepts a profile
but currently uses it only to open the database read-only for the worker;
load_persistent_rows() then uses the full loader without applying the
allowlist. Consequently, a SQLite-backed federation worker holds data such
as user password hashes, token hashes and stored signing-key ciphertext
that the PostgreSQL worker profile excludes. Media blob bytes remain
read-on-demand rather than all being hydrated.
Read-only mode prevents writes to the file; it does not provide this
in-memory least-privilege boundary. This remains an open gap requiring
profile-aware SQLite hydration and a regression test.
One dormant write path found during review: state_conflict_resolver
(local_http_router.cpp:1255, wired unconditionally by
wire_federation_callbacks, never overridden by the worker) calls
database::store_state — a write to current_state — when a PDU's
ingestion result carries a populated state_conflict. In the worker,
pdu_sink is always the IPC-relay override
(federation_worker::deserialize_pdu_ingest_result), which never
populates PduIngestionResult::state_conflict, so this resolver is
registered but never actually invoked from inside the worker today. It
is not relayed and not removed: current_state is already in the
allowlist for reads, the write path is currently unreachable, and — were
a future change to make it reachable — the SELECT-only grant would
refuse the INSERT/UPDATE rather than silently succeed, so the
failure mode stays fail-closed even if this invariant is ever broken by
accident.
federation_worker_table_allowlist (C++) and
provision-federation-worker-role.sql's per-table GRANT SELECT list
are the two authoritative places for "which tables the worker can read"
and must name the exact same set — tests/unit/test_worker_db_uri.cpp
parses both from the source tree and asserts they match, so the two
cannot drift silently.
Every table classified¶
tests/unit/test_worker_db_uri.cpp also asserts every table
migrations/*.sql creates falls into exactly one of three buckets, so a new
migration's table cannot go unclassified:
- Worker allowlist (13 tables, profile-gated reads —
database::federation_worker_table_allowlist):rooms,membership,current_state,events,event_edges,event_auth,event_signatures,room_aliases,server_signing_keys(column-restricted, see above),state_groups,state_group_state,event_state_groups,forward_extremities(the last four added in 0.12.13 by ADR-0064 phase A —state_groupswas vestigial before this and is now moved out of the never-reads list below). - Worker never reads (47 tables):
users,devices,access_tokens,refresh_tokens,federation_destinations,federation_transactions,invites,state_transitions,sync_stream_watermark,event_stream_watermark,device_keys,one_time_keys,fallback_keys,cross_signing_keys,key_signatures,key_backup_versions,key_backup_sessions,media,media_blobs,remote_media,audit_log,admin_actions,policy_rules,account_data,room_account_data,to_device_messages,device_list_changes,presence_state,filters,profiles,account_threepids,client_txn_ids,pushers,notifications,openid_tokens,login_tokens,appservice_txn_cursor,appservice_outbox,deferred_pdus,receipts,appservice_directory,media_pending_uploads, plus six tablesschema.cppdeclares but that no runtime code path (main's or the worker's) ever populates or queries —event_json,key_backups,push_rules,rate_limits,room_versions,state_group_edges— vestigial from earlier schema iterations, confirmed by grep to appear nowhere outsideschema.cpp's own DDL declarations.state_group_edgesremains vestigial after ADR-0064 phase A: the design uses parent links onstate_groupsitself for the delta chain, not a separate edges table, so phase A deliberately left it alone. Whether to drop it is an open question for a later phase, not decided here.federation_destinations/federation_transactionsspecifically are read only byfederation::DispatchWorker, which never starts in the worker process: its construction (local_http_router.cpp, guarded inside the same lazily-wired callback block) requiresruntime.database.signing_secret_keyto hold a real 32-byte secret, and the worker's runtime never loads one (crypto_provider_overridden = true, per ADR-0015/ADR-0062 part 1) — the construction check fails and returns before the worker ever touches either table, main or not.profiles,device_keys/one_time_keys/fallback_keys/cross_signing_keys/key_signatures, anddevicesare read only byprofile_query_provider/device_keys_query_provider/one_time_keys_claim_provider/user_devices_provider— all four overridden by the worker to relay to main over IPC instead (src/federation_worker/AGENTS.mdrule 3), so their default, table-reading implementations are wired but never invoked inside the worker. - Read by every process, not profile-gated (1 table):
schema_migrations.
Explicit, logged opt-out: federation.worker.
allow_shared_database_credentials=true lets the worker share main's login
when a separate role cannot be provisioned, restoring the pre-0.12.13-part-2
behaviour. config::validate() rejects database.backend=postgresql with
security.federation.enabled=true and an empty database_uri_file unless
this is set (src/config/config.cpp), and homeserver::WorkerPool::
WorkerPool logs CRITICAL on every startup while it applies, so the
downgrade cannot go unnoticed. Ignored entirely for
database.backend=sqlite: a single shared file offers no PostgreSQL role
boundary. The SQLite connection is read-only in the worker, but the current
SQLite row loader uses full hydration and does not enforce the worker table-load
profile. The profile therefore is not an in-memory protection on SQLite yet
(see the open gap above and ADR-0062 part 2, "Positive Consequences").
Migration role never reachable from the worker. The worker's own
connection always passes empty runtime_role/migration_role to
open_postgresql_persistent_store (cleared by apply_worker_database_uri),
so it can never SET ROLE to :migration_role even if a compromised worker
tried — and in the normal startup order the worker only ever connects after
main has already brought the schema to current_schema_version(), so the
migration branch inside open_postgresql_persistent_store (schema.version <
current_schema_version()) is unreachable from a worker connection in
practice regardless.
No role is created by a migration. PostgreSQL roles are cluster-level
objects, not part of any one database's schema, so — consistent with
provision-roles.sql — provision-federation-worker-role.sql is a
separate, operator-run provisioning script, never a file under
migrations/.
Security audit follow-up (0.12.17)¶
rooms.directory_public is TEXT NOT NULL DEFAULT 'false' in the v1 schema. It is independent of room join rules; publication is an explicit, durable choice (ADR-0099). Creation persists publication with the room and initial membership. Visibility changes commit before updating the store and runtime mirrors. PostgreSQL scoped snapshots bind one room ID and join relation tables to events, avoiding the 128-parameter ceiling for large rooms. Media moderation commits flags, removal blob state and required administrative audit rows together; mirrors change only after commit (ADR-0100).
Membership rows commit with their event (DB-4, 0.12.26)¶
database::MembershipProjection carries the membership row, and the invite metadata change, that an
m.room.member event implies. store_event_with_state and prepare_store_event_with_state take one and
write it in the event's own transaction (an upsert on membership, and an upsert or keyed delete on
invites), so the event, its current_state row, the row and the invite change are durable together or
not at all, on both backends. store_membership_projection writes a row and invite change with no event in
one transaction. reconcile_memberships_with_current_state runs at main's hydration (TableLoadProfile::full only; the federation worker never writes main's rows): a row is rewritten to the
current member event's membership when the row is missing, says join while the event does not, or is older
than the event; a newer row is kept. Corrected rows get a fresh stream ordering and are corrected in memory
even if the write fails. No schema change (ADR-0149).
Write connection pool (DB-7, 0.12.26)¶
Persisted writes reuse connections from a per-store ConnectionPool instead of opening one per
transaction: at most database.pool_size (16) are in use at once, and a write waits for a free one.
PostgreSQL connections assume the runtime role once when opened; a pooled one is reused only while idle
(PQTRANS_IDLE) with nothing to read on its socket, so one the server has closed is replaced. SQLite
connections keep their pragmas and foreign_keys=ON. Reads, migrations and the worker's read-only store
still open their own connections. write_connections_opened reports how many the pool has opened
(ADR-0154). A slot is given back on every way out of an acquisition, a throwing open or liveness check
included: waiting for a slot has no timeout, so a slot that leaked would be lost for good.
An SQLite database file is created 0600 and must not be a symlink. Directories that already exist are
followed as the kernel resolves them, symlinks included (an operator's /var/lib/merovingian pointing at a
data volume); any directory the server creates is made 0700, and everything below it is opened without
following links (DB-10).
Those checks never open an existing database file: closing any descriptor of it would release every POSIX
lock the process holds on it, including another connection's write lock (ADR-0157). An existing file is
checked by name (fstatat with AT_SYMLINK_NOFOLLOW), and a read-only connection reads the header, to
refuse WAL, through SQLite's own handle before any statement runs.
A read-only connection (the worker's store and room reloads, media blob and audit reads) can be told a
journal is hot when it is not: SQLite checks that the journal exists and then that no process holds
RESERVED, and a writer that commits between the two checks has already deleted its journal and unlocked.
A read-write connection settles that under EXCLUSIVE; a read-only one fails at once with
SQLITE_READONLY_ROLLBACK. The open-time pragmas, prepare and every read's first step retry that one
result every 10 ms for up to 2 s; a journal that is really hot still fails the read, and main's next
read-write connection rolls it back (ADR-0159). A failed room snapshot logs room_snapshot.read_failed
with its stage and extended SQLite code, and a connection whose open-time pragmas fail logs
connection.rejected with the code.
A room reload (reload_room) reads the room's eight tables in one read transaction, so its rows
come from one committed state: BEGIN DEFERRED on SQLite, whose first read takes SHARED until the
end (main's commits wait for it), and REPEATABLE READ READ ONLY on PostgreSQL. The worker's
whole-store startup load stays one statement per table, so it never holds SQLite's SHARED lock for
the whole load (ADR-0161).
Event table indexes (0.12.39)¶
PersistentStore::events is a database::EventTable (include/merovingian/database/event_table.hpp),
not a std::vector. It owns the in-memory rows of the events table and keeps two indexes over them,
both holding positions in its row vector so they survive reallocation (ADR-0215):
- by event ID.
events.find(id)andevents.contains(id)are hash lookups, andevents.find_in_room(room_id, id)adds the room check.events.with_id(id)is a range of zero or one rows, for a loop whose body runs for the match. Use these, never afind_ifover the rows. - by stream order.
events.in_stream_order()visits every row by ascendingstream_ordering. Rows with equal stream orderings come in the order they were added. Reverse it for newest first. Iterating the table itself visits arrival order, which is not stream order: SQLite hydration has noORDER BY, PostgreSQL hydrates in event-ID order and moves a row whose status is updated, and stream orderings commit out of order across rooms.
Rows are read-only through the table. The only ways to change it are:
| Change | Method | Callers |
|---|---|---|
| Add one event (refused if its ID is already held) | insert() / push_back() |
store_event, apply_store_event_with_state |
| Add many, sorting their stream order in one pass | insert_all() |
SQLite and PostgreSQL hydration, reload_room, room snapshots |
Change a row's fields other than event_id and stream_ordering |
modify() |
set_event_status, replace_event_json (redaction), reconstruct_event_relations |
| Remove rows, renumbering both indexes in one pass | erase_if() |
reload_room |
| Move every row out | extract() |
room snapshot loaders |
modify() puts event_id and stream_ordering back if a mutation changes them. The table has no lock
of its own: like every other PersistentStore member, it is guarded by runtime.mutex in the homeserver.