Cells must not claim sovrn.at in Stalwart: use an internal domain for service accounts
closedProblem
Every cell’s Stalwart plan (nix/stalwart/cell.plan.json, the Domain upsert named @mailDomain@ = fleet.mailDomain = “sovrn.at”) creates sovrn.at as a mail domain on that cell. So sovrn.at can’t be hosted like any customer domain: an operator should be able to sign in on a cell and add sovrn.at as a hosted email domain through sovrnd, with sovrnd owning its records, accounts and DNS checks. Today the domain already exists in Stalwart on every cell, outside sovrnd’s database.
Seen on mx99 (2026-10-08): sovrnd’s reconciler logs at ERROR every 5 minutes:
verifier: reconcile: stalwart domain has no DB row; NOT deleting (investigate DB corruption/misconfiguration) (orphan.report, name sovrn.at; internal/verifier/orphan.go). It refuses to delete it (correctly), but it’s error-level noise and would count toward CellErrorBurst.
What the domain is used for today
- sovrnd’s service account. nix/stalwart/sovrnd.plan.json creates the account sovrnd authenticates as over JMAP (HTTP Basic, username sovrnd@
; nix/modules/roles/cell.nix sets stalwart.username = “sovrnd@${fleet.mailDomain}”). The relay’s route-sync account (relay.plan.json) follows the same pattern; check it. - Server default domain. cell.plan.json’s SystemSettings update sets defaultDomainId to it.
- The MX hostname’s certificate. The same Domain carries certificateManagement (ACME, SAN = the cell hostname, e.g. mx99.eu.sovrn.at), so the cell’s SMTP/IMAP TLS certificate hangs off it.
Wanted
- If Stalwart needs a domain for service accounts (and as the default), make it an internal, non-routable domain that can never collide with a customer or company domain, e.g. a reserved name under .invalid or .internal per cell (sovrn.internal /
| .internal). Nothing external should ever see it: no MX, no mail for it. | - Keep the MX hostname certificate working: either on the internal domain’s certificateManagement (SAN = cell hostname; check Stalwart accepts a SAN outside the domain) or on its own object if 0.16 allows that.
- Service account usernames move to the internal domain ([email protected] or similar): plan files, cell.nix’s stalwart.username, relay.plan.json, sovrnd config, tests (checks.stalwart asserts accountName = sovrnd@
; checks.cell; stalwart-plan). - The reconciler should know the internal domain is not a tenant (skip it explicitly), so no orphan reports.
- Then sovrn.at becomes an ordinary hosted domain: added through sovrnd on whichever cell should host it, like any customer.
- Don’t conflate with other uses of “sovrn.at”: fleet.mailDomain is also used for the R2 bucket name derivation (nix/modules/backup.nix:
| . | .sovrn.at -> - ), landingHost/rootHost (the website), the service DID did:web:sovrn.at, and notify.sovrn.at for outbound senders. Those are about the company’s web and cell naming, not a hosted mail domain; split the setting so the internal mail domain is its own value. |
Migration
Cells are pre-beta (mx99 staging only), so a rebuild or a plan reconcile that deletes the old sovrn.at Domain is fine. Make sure the plan reconciles it away rather than leaving it behind on existing cells.
Found during fleet Phase 9 (mx99 acceptance), ~/projects/servers docs/fleet-migration-plan.md.
3 Comments
Plan
Findings (Stalwart 0.16.23 / stalwart-cli 1.0.12 source): - An explicit SAN list is ordered verbatim (
build_domains), so a SAN outside the domain works:sovrn.internalcan carry the MX hostname’s certificate. Certificates are separate objects matched by SAN set (acme_certificate_by_domains), so withreuseKeythe renewal under the new domain keeps the existing key: DANE 3 1 1 TLSA stays valid. -applyruns every destroy before any upsert, and Stalwart refuses to destroy a linked object (objectIsLinked). The old Domain is linked from SystemSettings.defaultDomainId and the old sovrnd account. So a single converge can’t remove it, and a permanentdestroy Domain name=sovrn.atop would also destroy sovrn.at again on every restart once it is hosted through sovrnd. The migration is therefore a one-off on existing cells (mx99), not a plan op. -domain.Ensurealready refuses to adopt a Stalwart domain owned by no tenant (AssignedError), which is also why adding sovrn.at through sovrnd fails today.Changes: 1. fleet.nix: split
mailDomainintoserviceDomain = "sovrn.internal"(Stalwart’s own domain: service accounts, default domain, MX certificate) andcellZone = "sovrn.at"(the zone cell hostnames live under; backup.nix bucket naming). 2. cell.plan.json / dev.plan.json: the Domain is@serviceDomain@/sovrn.internal; [email protected] everywhere (cell.nix, Justfile, serve-stalwart.sh, README, tests). The relay keeps its hostname as its domain: mxb.eu.sovrn.at is never a hosted domain and runs no reconciler. 3. sovrnd:domain.IsInternal(name)(the.internalTLD, ICANN-reserved for private use). The verifier’s reconcile pass skips internal Stalwart domains (debug log, no orphan.report); ProvisionDomain rejects them with ErrInvalidDomain. Unit tests for both. 4. Migration on mx99 after deploy: delete the account [email protected], then the Domain sovrn.at (stalwart-cli as the recovery admin). After that, sovrn.at can be added through sovrnd on its chosen cell.Implemented (working copy, not yet committed)
mailDomainsplit intozone = "sovrn.at"(website apex + cell hostnames; backup.nix bucket naming) andserviceDomain = "sovrn.internal".@serviceDomain@(still carries the MX hostname certificate via explicit SAN); dev.plan.json uses sovrn.internal too. sovrnd authenticates as [email protected] (cell.nix, Justfile, serve-stalwart.sh, README). Relay unchanged (its hostname is its domain, never hosted, no reconciler).domain.IsInternal(.internal TLD). Verifier reconcile skips it (no orphan.report); ProvisionDomain rejects it with ErrInvalidDomain (400). Tests: TestIsInternal, TestSweepOrphanSkipsInternalServiceDomain, TestProvisionDomainRejectsInternal.Migration (one-off on mx99, after deploying)
Rehearsed locally on Stalwart 0.16.23: old cell plan, then new plan (adds sovrn.internal + [email protected], moves the default; sovrn.at and [email protected] stay), then:
Re-converge afterwards is a no-op. Domain delete leaves the Certificate object (matched by SAN), so the MX key and its TLSA record are unchanged. Then add sovrn.at through sovrnd on its cell.
Dev: the existing dev Stalwart keeps sovrn.test + [email protected] until
sovrn-reset(or the same two deletes).Cross-repo: ~/projects/servers reads
fleet.mailDomain(Justfile:296, scripts/update-dns.sh, scripts/check-dns.sh); rename tozonewhen bumping the sovrn input.Deployed to mx99 (2026-10-10)
mailDomain->zonein Justfile_dns, check-dns.sh, update-dns.sh; sovrn lock bump).just check-dnspasses with the rename.just deploy mx99.eu.sovrn.at: converge22 updated, 2 created (0 failed); sovrnd authenticates as [email protected], healthz stalwart ok. The ACME task under sovrn.internal reported the certificate still valid (matched by SAN), so no new order.Next: add sovrn.at through sovrnd on mx99 as a hosted domain.