Fix the domain/tenant mismatch that failed the second live migration

The second production attempt failed during recovery-mode apply, with the
mail server already stopped and the store already at schema v6:

    create Account restore-13: invalidForeignKey | Object id: Domain#d

v0.16 requires a tenant-scoped Account to sit on a Domain owned by that
same tenant, for its primary domain and for every alias. v0.15 imposed no
such rule, and migrate_v016.py carries the two facts over independently:
_build_domains sets a domain's memberTenantId only for domains declared as
their own `domain` principal with a `tenant`, while _build_user sets the
account's from the account's own record. A domain that exists only inside
an email address is inferred, gets no tenant, and every tenant-scoped
account using it is then rejected.

Established by reproduction rather than inference: a synthetic v0.15
principal dump, run through the unpatched upstream converter and applied to
a real 0.16.14 in recovery mode, reproduces the error character for
character - the `#d` is the server's own object id for the offending
domain, not a plan client-id. The same harness establishes which directions
are constrained: a tenant-scoped account on a tenant-less domain or on
another tenant's domain is rejected; a global account on a tenant-owned
domain is accepted.

  - applyplan.ReconcileDomainTenants repairs the plan between convert and
    apply. Where a tenant-less domain is used only by accounts of one
    tenant, the domain adopts that tenant - the sole assignment that both
    applies and keeps every account. Where accounts genuinely disagree it
    changes nothing and reports why, because forcing such a plan through
    would mean dropping mailboxes.
  - stalwartapi.FetchTenantLayout maps tenant membership over the 0.15 REST
    API and predicts the outcome with the same rule the server enforces, so
    preflight either warns about the domains that will adopt a tenant or
    fails - while the service is still running.
  - The plan is parsed generically rather than through the typed Operation.
    A real export.json mixes shapes: `create` maps a client-id to an object,
    `update` carries a flat one. The typed form failed on the first `update`
    line, found by running against actual converter output. Numbers decode
    as json.Number so a 10 GiB quota is not rewritten as 1.073741824e+10.

Corrects the record: the previous commit claimed the converter emits every
Account with `tenantId: null` and made preflight refuse every multi-tenant
install on that basis. The field is memberTenantId, the converter does
populate it, and the export had been inspected for a key no version of the
script ever writes. The refusal is now narrowed to what v0.16 genuinely
cannot represent.

The same fix has been prepared for migrate_v016.py upstream. The tool
downloads that script rather than vendoring it, so the repair stays here
until a released version carries it, and is a no-op on a consistent plan.
This commit is contained in:
2026-08-24 00:27:26 -07:00
parent c29140b6b3
commit e955a41d58
9 changed files with 1266 additions and 32 deletions
+9 -6
View File
@@ -209,12 +209,15 @@ func accountKey(p restPrincipal) string {
// TenantNames returns the tenant principals on a v0.15.x instance.
//
// Multi-tenancy has to be detected before a migration starts, because
// Stalwart's own converter does not survive it: it emits the Tenant and the
// Domains correctly and then every Account with `tenantId: null`, so the
// account references a tenant-owned domain while belonging to no tenant and
// the apply is rejected with `invalidForeignKey`. Observed on a real
// migration, at the point where the mail server was already stopped.
// Multi-tenancy has to be established before a migration starts. v0.16
// requires a tenant-scoped account to sit on a domain owned by that same
// tenant; v0.15 did not, and Stalwart's converter carries the two facts
// over independently, so an install that is valid today can convert into a
// plan the new server rejects with `invalidForeignKey` on the Domain
// reference - during the recovery-mode apply, with the mail server already
// stopped and the store already at schema v6. See FetchTenantLayout, which
// builds on this to predict that outcome, and applyplan.ReconcileDomainTenants,
// which repairs the plan.
func (c *Client) TenantNames(ctx context.Context) ([]string, error) {
tenants, err := c.restPrincipals(ctx, "tenant")
if err != nil {