Skip to content

Production v1.0.0 — Open Items

Production means all security, correctness, conformance, platform, packaging, and release evidence is closed for a signed release artifact. Packages must not be published as production releases while any blocking gate remains open.

Reassessed 2026-08-31 (0.12.2). The previous charter conflated "we must know this" with "CI must prove it on every pull request", and carried at least one item that could never be checked off. Both distort the release decision: an unfalsifiable gate blocks 1.0.0 forever, and a gate CI cannot honestly evaluate produces a green badge that means nothing — which is more dangerous than no check at all, and is a failure mode this project has already paid for. What follows separates what blocks the tag, what is evidence gathered at tag time, and what has been deliberately dropped.

What blocks 1.0.0

Blocker Why it blocks Done when
Federation conformance is entirely self-attested Every conformance claim rests on this project's own tests. Complement is the only external check on whether the federation implementation is actually correct. Complement runs green against a release candidate — see "Release evidence" for why this is a pre-tag run, not a per-PR gate.

Release evidence, not CI gates

Soak, load and chaos runs cannot produce meaningful numbers on shared, time-limited CI runners. Thresholds loose enough to survive runner noise prove nothing, and thresholds tight enough to mean something flake — and a green soak badge that means nothing is worse than an absent one, because it invites the conclusion that concurrency has been checked.

These are therefore maintainer-run, recorded at tag time alongside the compiler version, linker flags, dependency versions, checksums and signatures that release notes already carry:

  • Soak and load. tests/integration/test_runtime_lock_soak_flow.cpp, opt-in behind build_load_tests, run on real hardware against a release candidate. Record throughput and p50/p95/p99 latency per category. Numbers from real hardware are stronger evidence than a CI figure, not weaker.
  • Chaos. Run against a candidate if run at all; not a per-PR gate at any point on this project's roadmap.
  • Complement. A full run against the candidate, its output attached to the release. Wiring it per-PR is desirable later but is not what 1.0.0 needs.

Property tests are the exception: they are cheap, deterministic, and belong with the existing fuzz targets rather than in a milestone of their own. Fold them into fuzz.yml when convenient — not a release gate.

Decided against

Recorded so these are not silently reintroduced by a later audit.

  • "Complete full Matrix v1.19 conformance, persistence and endpoint coverage." Unfalsifiable as written: no one can ever mark it done, so it blocks 1.0.0 permanently while communicating nothing. Superseded by the specific rows above plus whatever Complement actually fails.
  • /search backed by a real index. The in-memory bounded scan is adequate for the small-to-medium deployments this server targets. What was missing was not an index but an honest statement of the limit; the scope and cost are documented in capability-gaps.md and that is the decision.
  • Config-profile capability-gate naming. Cosmetic CI job renaming with no bearing on whether the software is correct. Dropped from the milestone.
  • Load/soak/chaos as CI gates. See above — moved to release evidence.

Closed in 0.12.2: /messages honours lazy_load_members. Investigating it found the flag was never parsed anywhere in the codebase, and /messages additionally 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 state is restricted to senders appearing in the chunk. Non-member state is unchanged — it is relevant to rendering the chunk regardless.

Note the endpoints differ: /context returns "the state of the room at the last event returned" and reconstructs temporally, while /messages returns "state events relevant to showing the chunk". Copying /context's approach here would have been the wrong fix.

Reinstated in 0.12.3, having been withdrawn in error. The migration code does exist — open_sqlite_persistent_store and open_postgresql_persistent_store each call apply_pending_migrations when the stored version is below current, each step in its own transaction — so the path is not missing. But nothing exercises it. The scenario cited when withdrawing this ("bootstraps a fresh migrated schema") runs with the default PostgreSQL config and no URI file, so startup falls back to the in-memory open_persistent_store() and never reaches either backend's migration branch; the SQLite restart scenarios open a database the same binary just created, so version < current is false and the branch is skipped. The original wording — an upgrade path that exists but is untested — was accurate.

Closing it needs a database seeded at an older schema version through a real file-backed store, which needs a way to open at a target version rather than straight to current.

Closed in 0.12.3: PostgreSQL privilege separation is applied, not just provisioned. set_postgresql_role/reset_postgresql_role and a role-separation integration scenario already existed — with no callers, so a deployment that had run provision-roles.sql still served every request with its login role's full privileges. open_postgresql_persistent_store now assumes the migration role for the DDL phase, drops to the DML-only runtime role to serve, and refuses to open if a configured role cannot be assumed. Role names come from database.migration_role/database.runtime_role; both empty issues no SET ROLE, so existing single-role deployments are unaffected by upgrading.

Verification note: the open-path scenario needs a live PostgreSQL URI and the role environment variables, so it skips locally and runs in the postgres-integration workflow. The config-parsing half runs everywhere.

Still open, not blocking

Tracked, with a decision already recorded, but not gating the tag.

Item State
Global runtime lock Partially closed, with measurement, in 0.12.1. HomeserverRuntime::mutex is still held for the whole of every client-server request and every inbound federation transaction — that has not changed. Two real, previously-undiscovered lock-safety bugs were found and fixed by writing regression tests first (tests/integration/test_request_lock_contention_flow.cpp): (1) resolve_policy_server_hook's call to trust_safety.policy_server_url was fixed for inbound federation in #415/0.11.13, but register_local_user, create_room, and the media download/thumbnail policy check all called it directly while still holding the lock — an operator with trust_safety.enabled inherited a policy server that could freeze registration, room creation, and media reads for every other user. (2) create_room self-locks runtime.mutex (a recursive_mutex, so it stays independently callable); calling it from a handler that already held the lock silently double-locked it, so NetworkIoUnlock released only the outer level and the mutex stayed effectively held for the whole "unlocked" network call — this means 0.11.13's NetworkIoUnlock mechanism was incomplete for any call chain with a second, self-locking function on the stack, not just an oversight in call-site coverage. Both are fixed; see docs/http-transport.md "resolve_policy_server_hook" and "One release primitive, and why it drains every level". join_room/leave_room share create_room's self-locking shape and plausibly the same gap for their own outbound federation calls — not fixed, not yet confirmed either way, tracked as separate follow-up work (spawned task, not yet landed). A new opt-in load/soak harness (tests/integration/test_runtime_lock_soak_flow.cpp, build_load_tests) measured the general hot path (concurrent /sync long-polls, reads, message sends, and signed inbound federation transactions, trust_safety disabled) before and after this change over a real 20 s run: throughput and p50/p95/p99 latency for reads (~374–385 req/s, p50 ~7 ms, p95 ~25–26 ms), sends (~469–478 req/s, p50 ~7 ms, p95 ~21–22 ms), and federation transactions (~204–208 req/s, p50 ~6–7 ms, p95 ~23 ms — capped by the federation per-origin transaction rate limiter, not lock contention) were statistically indistinguishable before and after. This is expected, not a null result to be alarmed by: neither fix touches that hot path (trust_safety is off by default and room creation is not in the harness's timed phase), so the evidence does not show a general-throughput case for narrowing sync/read/send/federation further, and none was attempted. The harness also recorded a /sync long-poll anomaly (occasional ~20 s stalls) present symmetrically in both the before and after runs — not attributable to this change, not yet root-caused, noted for follow-up.

/search and the /messages divergence previously sat here; /messages is now a blocker above and /search is under "Decided against". Push moved to a blocker for the silent-discard behaviour specifically, not for retry/backoff.

Closed

Retained as the record of what was verified and when.

  • ~~Push silently discards email pushers.~~ Closed in two steps. 0.12.3 refused kind: "email" at registration instead of storing a pusher that never delivered (this row stayed listed as a blocker after that by mistake). 0.12.44 delivers them when server.email.smtp_url names an SMTP relay, and still refuses them without one: only to an email 3PID the account holds, as a delayed, throttled digest that carries the unread count alone, over libcurl with TLS required except to a loopback literal (ADR-0219, ADR-0220). tests/integration/test_email_push_flow.cpp drives real SMTP sessions against a loopback relay.

  • ~~No tested upgrade path across releases.~~ Closed (0.12.42, ADR-0216): tests/integration/test_database_upgrade_flow.cpp opens the SQLite database and PostgreSQL dump that release 0.11.13 wrote over HTTP (tests/fixtures/upgrade/0.11.13/), migrates them from schema 11 to current and serves them: old access tokens, passwords, messages, aliases, media, profile, account data, pushers, device keys, an old sync token, and a new event signed with the old signing key. migrate_sqlite_schema_to / migrate_postgresql_schema_to build a schema at any older version for synthetic per-version scenarios. SQLite runs in the normal suite, PostgreSQL in postgres-integration. The room directory is not carried over from 0.11 (owner decision: no deployments yet). Superseded in 0.12.47 (ADR-0224): the migrations collapsed into one frozen v1 schema, the 0.11.13 fixture and its scenarios were removed, a pre-1.0 database is refused at open, and the next fixture comes from the last 1.0.x release when 1.1 starts. The run found two defects that affected every deployment: PostgreSQL never loaded profiles, and both stores read the migration ledger in text order.

  • ~~Keep real listener coverage in CI and prove the server serves requests until stopped by the service manager.~~ Closed (0.12.1): audited the existing test_server_startup_hardening_flow.cpp binary-spawn test and found it only proved "stays alive and shuts down", never actually served a request — and that in every existing CI job it WARN-skipped before reaching even that, because the runtime hardening self-check can only reach ready=true on a hardened-profile build (debug builds lack _FORTIFY_SOURCE) run by a process holding CAP_SETPCAP (root containers fail the privilege-drop check instead), a combination no existing job satisfied. Fixed by: (1) adding 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 the scenario itself; (2) a new ubuntu-hardened-listener-coverage CI job (.github/workflows/ci.yml) that builds the hardened profile and grants the built merovingian-server binary CAP_SETPCAP via sudo setcap (GitHub-hosted runners have passwordless sudo) so the scenario actually reaches the live-serving assertions instead of skipping. Verified locally: a --profile hardened build resolved every self-check blocker except capability-bounding (which needs CAP_SETPCAP, unavailable in the local sandbox used to verify this work — the setcap grant itself was not re-verified end-to-end outside that sandbox; if it does not work as expected on a GitHub-hosted runner the scenario will WARN-skip exactly as before, not silently pass).

  • ~~Require configured TLS with validated certificate and private-key files for public listeners; keep loopback cleartext available for reverse-proxy deployments.~~ Implemented with explicit reverse_proxy declaration.

  • ~~Store access tokens only as versioned cryptographic hashes generated from LibSodium CSPRNG output.~~ Implemented: access and refresh tokens are persisted as domain-separated BLAKE2b digests (token-hash:v2/v3/v4), master-key-derived when a master key is configured. See docs/auth-identity.md.

  • ~~Store passwords only with LibSodium Argon2id password hashes.~~ Implemented: crypto_pwhash_str/crypto_pwhash_str_verify (Argon2id) is used for account passwords and registration tokens.

  • ~~Enforce PostgreSQL transaction coverage, migration coverage, and role grants against real temporary databases.~~ The CI-level coverage this bullet describes was already closed before this pass: .github/workflows/postgres-integration.yml provisions real merovingian_migration/merovingian_runtime roles against a live PostgreSQL 16 service container and tests/integration/test_postgresql_persistence_flow.cpp proves transaction rollback, migration ordering, and that a runtime-role session is denied DDL. Related packaging gap found and partially closed (0.12.1): packaging/ provisioned no PostgreSQL roles at all for real deployments — added packaging/postgresql/provision-roles.sql, the same pattern CI already proves, for operators to run. Still open: merovingian-db-migrate prints an offline migration plan and does not connect to a database. The homeserver startup path applies pending migrations and enforces configured PostgreSQL migration/runtime roles; see docs/database-persistence.md for the current boundary.

  • ~~Fail closed when required production hardening controls are unavailable.~~ Closed (0.12.1): found the actual fail-open gap — not in hardening_self_check.cpp (already strictly fail-closed: every check must report enabled, unknown blocks same as disabled) but in apply_worker_hardening() (src/platform/runtime_hardening.cpp), which on every non-Linux platform unconditionally returned accept() regardless of whether the operator had requested worker hardening (federation.worker.apply_hardening=true, the production default) — the federation worker would log "runtime hardening applied (seccomp filter active)" and start handling untrusted federation traffic completely unsandboxed on FreeBSD/OpenBSD/NetBSD, with zero test coverage of this function anywhere in the suite. Now returns a fail-closed rejection (worker_hardening_unavailable_decision()) naming the unavailable controls and the federation.worker.apply_hardening=false escape hatch; the worker process refuses to start rather than run unsandboxed, so the WorkerSupervisor sees a failing child and federation degrades to 503 instead of ever accepting unsandboxed traffic. MEROVINGIAN_TEST_DISABLE_HARDENING is unaffected (a separate escape hatch, unrelated to this function).

  • ~~Add signed release artifacts, reproducible builds, dependency pinning policy, license review, provenance, and artifact signatures.~~ Implemented in 0.10.38: GPG .asc signatures on tarballs and packages, SLSA provenance, SBOM and license-summary artifacts, immutable [wrap-file] dependency pinning with SHA-256 hashes, and byte-for-byte reproducible static Linux tarball verification.

  • ~~Record compiler version, linker flags, dependency versions, test logs, sanitizer logs, fuzz target names, package checksums, and GPG signatures in release notes.~~ Closed (0.12.1): checksums and GPG signatures were already in the published 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 flags landed via scripts/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 — folded into the published release notes by publish-alpha-release alongside the existing checksums. Sanitizer logs are cross-referenced to the separate sanitizers.yml workflow run rather than duplicated (this release build does not itself run under a sanitizer).

| No HTTP keep-alive | Closed in 0.12.1 — src/homeserver/http_server.cpp now serves persistent HTTP/1.1 connections as sequential request rounds: keep-alive by default for 1.1, Connection: close honoured and echoed, 1.0 only on explicit request; each request's body is drained exactly so request boundaries are never lost (pipelining buffered and answered in order, out of scope); idle parking bounded by server.http.keep_alive_idle_seconds (default 15) plus a process-wide parked-connection cap server.http.keep_alive_max_connections (default 8 at 0.12.1 closure; current default 256); the phase-aware connection_guard (connection_should_close) keeps the slowloris kill for mid-request slow clients while exempting idle parks | Closed — see CHANGELOG 0.12.1 | | Rate limiting not production-grade | Closed in 0.12.1 — every client-server route is classified into one of six explicit tiers in http::rate_limit_tier_for() (auth_sensitive, media, sync, federation, admin, generic) with per-endpoint accounting; unauthenticated routes are bucketed per remote IP and the credential/enumeration surface resolves to the tighter auth_sensitive tier rather than the generic fallback; operators override per-prefix (client_rate_limits.per_ip.*), per-tier (client_rate_limits.tier.<name>, validated against the tier table so a typo is a parse finding) or the generic default, most-specific-first, and a misconfigured entry at any level fails closed (invalid_policy, issue #412). Inbound federation gains a per-X-Matrix-origin cap on non-/send endpoints (security.federation.per_origin_request_rate, default 600/min); /send keeps its weighted transaction/PDU/EDU trio so nothing is double-counted. In-memory counters remain by design — operator sign-off recorded in docs/http-transport.md | Closed — see CHANGELOG 0.12.1 | | Application Service API | Closed in 0.12.1 — registration files, as_token/hs_token, m.login.application_service, namespace exclusivity, outbound transaction delivery, all six GET /_matrix/client/v3/thirdparty/* third-party lookup routes (backed by outbound GET /_matrix/app/v1/thirdparty/* calls, with multi-appservice aggregation and unreachable-appservice degradation), and the outbound GET /_matrix/app/v1/users/{userId} / /rooms/{roomAlias} query hooks are all implemented — see CHANGELOG 0.12.1 and docs/todos/capability-gaps.md, "Application service API". The query hooks are invoked on a local miss: GET /directory/room/{roomAlias} and GET /profile/{userId} consult the appservice whose namespace covers the identifier and re-check locally afterwards (wired in 0.12.1, covered by test_appservice_query_hooks_flow.cpp) | Closed — see CHANGELOG 0.12.1 | | SSO login | Closed in 0.12.1 — m.login.sso is advertised from GET /login with identity_providers when server.sso.* is fully configured (fail-closed: a half-configured setup is not advertised), GET /_matrix/client/v3/login/sso/redirect[/{idpId}] is routed with a redirectUrl allowlist closing the open-redirect, and m.login.token is implemented (it was entirely absent — only password login existed) against a durable single-use login_tokens table, disjoint from access_tokens. | Merovingian does not itself speak CAS/SAML/OIDC; homeserver::complete_sso_login is the documented integration point for an operator's external IdP adapter. |