Tenant resolution answers one narrow question: which tenant snapshot should this execution use? It does not prove that the caller is allowed to act, that the tenant exists, or that the tenant may currently be served.
1. Request evidence assembled by the Host
TenantResolutionMiddleware converts HTTP-specific facts into TenantResolutionContext:
- authenticated caller type, tenant, user, application, and office;
Request.Host.Hostwithout the port;- request path and headers;
- the cached host-to-tenant map;
- Host operate-as and Development-only debug overrides.
The framework resolver does not reference HttpContext. This separation keeps priority logic testable and reusable for non-HTTP hosts.
2. Exact precedence
| Priority | Step | Evidence | Trust rule |
|---|---|---|---|
| 1 | SystemJob | explicit job field, then ambient context | caller must be a trusted system boundary |
| 2 | RootOperatorOverride | Host override or Development debug | Host type or environment gate |
| 3 | ApplicationCaller | authenticated application tenant | authentication fact |
| 4 | UserClaim | authenticated user tenant | authentication fact |
| 5 | HostSubdomain | exact host-map match | platform tenant registry |
| 6 | Header | X-Tenant | confirmation only |
| 7 | Path | first non-empty segment | confirmation only |
| 8 | SingleTenantDefault | 1000001 | compatibility fallback |
TenantResolver evaluates registered steps in order and stops at the first value for which TenancyDefaults.IsValid returns true.
3. User and application evidence
Application callers are evaluated before users. This matters when an authentication scheme can expose both identities: the application identity wins by design. A normal user request takes its tenant from the authenticated user claim.
var context = new TenantResolutionContext{ // Authentication established tenant-a; transport hints may only confirm it. AuthenticatedCaller = AuthenticatedUser(tenantId: "tenant-a"), Headers = new Dictionary<string, string> { ["X-Tenant"] = "tenant-b", }, Path = "/tenant-b/orders",};
var resolved = await resolver.ResolveAsync(context, cancellationToken);
// Both forged hints are ignored; the authenticated tenant remains authoritative.resolved.Value.Should().Be("tenant-a");The header and path steps return a value only when it equals the authenticated caller tenant. Anonymous requests cannot select a tenant through either source.
4. Path parsing is allocation-conscious
PathTenantStep examines the first non-empty segment with Span operations. It does not use Split, regular expressions, or LINQ. This is a hot-path implementation constraint protected by architecture tests.
The first segment remains a confirmation hint, not a general route value:
| Path | Authenticated tenant | Step result |
|---|---|---|
/tenant-a/orders | tenant-a | tenant-a |
/tenant-b/orders | tenant-a | no result |
///tenant-a/orders | tenant-a | tenant-a |
/tenant-a | anonymous | no result |
5. Host-to-tenant map
HostTenantMapProvider loads all global PlatformTenant rows with a non-empty Subdomain. It builds a case-insensitive dictionary and caches it under tenancy:host-tenant-map for ten minutes.
Matching uses the exact Request.Host.Host value. Port is omitted, but no suffix, wildcard, or public-suffix normalization is performed.
public async Task<Result> ChangeSubdomainAsync( ChangeTenantSubdomain command, CancellationToken cancellationToken){ var tenant = await tenantStore.GetAggregateAsync(command.TenantId, cancellationToken); tenant.ChangeSubdomain(command.Subdomain);
// Save the source of truth before invalidating its derived host map. await tenantStore.SaveAsync(tenant, cancellationToken); // All instances must observe invalidation when the cache is distributed. await hostTenantMapProvider.InvalidateAsync(cancellationToken);
return Result.Success();}The provider exposes invalidation, but correctness still depends on every subdomain mutation calling it. In a multi-instance deployment, the backing cache and invalidation must be shared or broadcast.
6. Privileged overrides
X-Operate-As-Tenant is captured only for an authenticated Host caller. X-Tenant-Debug is captured only when the host environment is Development. Both feed the same priority-two step.
The middleware currently skips TenantStatusGuard whenever either value is present. The step validates only with TenancyDefaults.IsValid, so an arbitrary non-empty, non-"0" value can become the effective tenant.
7. Fallback behavior
If no earlier step returns a valid value, the resolver returns the fixed default 1000001. This supports single-tenant compatibility, but it can also turn missing authentication or host mapping into a valid tenant silently.
For a strict multi-tenant deployment, choose and enforce one policy:
- fail resolution when no trusted source succeeds;
- enable the default only in an explicit single-tenant profile;
- or prove through configuration validation that the default tenant exists and is serviceable.
8. Conflicts and ambiguity
The current algorithm is priority-based, not conflict-rejecting. A trusted user claim for tenant-a and a host map for tenant-b resolves to tenant-a because the user step runs first. That may be desirable for shared API hosts, but custom-domain products may instead require the two facts to agree.
var claimedTenant = currentUser.Current?.TenantId;var mappedTenant = hostTenantMap.GetValueOrDefault(request.Host.Host);
// Authenticated traffic on a tenant-specific domain must agree with that domain.if (TenancyDefaults.IsValid(claimedTenant) && TenancyDefaults.IsValid(mappedTenant) && !StringComparer.Ordinal.Equals(claimedTenant, mappedTenant)){ // Reject the request before any tenant-owned repository can run. return Results.Problem( statusCode: StatusCodes.Status403Forbidden, title: "Tenant context conflict");}This is a product policy example, not behavior currently implemented by TenantResolver.
9. Status guard behavior
After installing the snapshot, the middleware calls ITenantGuard.EnsureActiveAsync unless the request is a Root override or Debug request. The Identity implementation:
- permits Active and GracePeriod;
- denies unknown and every other lifecycle state;
- permits System callers without store lookup;
- preserves an in-memory compatibility exception for unregistered
1000001.
Do not confuse TenantTransitionGuard with TenantStatusGuard. The former validates lifecycle writes; the latter validates whether a tenant can receive traffic.
10. Resolution tests that matter
Tests should cover more than step order:
- forged header and path cannot replace an authenticated tenant;
- Host map is exact and case-insensitive;
- subdomain mutation invalidates all application instances;
- unknown, suspended, and deactivated override targets are denied;
- Development debug is absent from non-Development hosts;
- no trusted source fails in strict multi-tenant mode;
- ambiguous host and identity facts follow the selected product policy.
11. Source review
# Registration order is part of runtime behavior.rg -n "AddScoped<ITenantResolutionStep" \ src/Hosts/BitzOrcas.Api/Composition/CoreRuntimeRegistration.cs
# Review every source and the first-valid resolver rule.rg -n "class .*TenantResolutionStep|TenancyDefaults.IsValid|ResolveAsync" \ src/Framework/BitzOrcas.Application -g '*.cs'
# Review privileged header capture and status-guard bypass together.rg -n "X-Operate-As-Tenant|X-Tenant-Debug|EnsureActiveAsync" \ src/Hosts/BitzOrcas.Api/Middleware/TenantResolutionMiddleware.cs