Files
suaveplan/openspec/archive/2026-05-26-foundation-fix-gap-5-default-tenant/proposal.md
2026-05-27 23:29:53 +01:00

2.9 KiB

api-host — Foundation gap 5: configurable default tenant

Package: @suaveplan/api-host Status: PROPOSED Type: behavior-change

Why

packages/api-stack/api/api-host/src/request-pipeline/request-pipeline.ts hard-coded a {id: "public" as TenantId, slug: "public"} fallback whenever the configured TenantResolverChain returned null on an auth-optional route. The "public" literal became load-bearing — every handler that introspected ctx.tenantId had to treat the string "public" as a sentinel rather than receiving an honest absence.

This blocked deployments that:

  • Genuinely run multi-tenant traffic where there is no public tenant row in the database, and want resolver miss → 400 not silent fallback.
  • Run an actual tenant with id="public" and need to distinguish "the literal public tenant" from "no tenant resolved".
  • Want to defer the policy choice (null vs. stub vs. error) to the operator's bootstrap module rather than the framework.

What changes

ADDED: HostConfig.defaultTenant?: TenantContext | null and RequestPipelineOptions.defaultTenant?: TenantContext | null.

MODIFIED: §6 step 1 ("Tenant resolve") of the request pipeline.

  • When the resolver chain returns null AND the route's auth policy is undefined/null (auth-optional):

    • defaultTenant: undefined (the new default) → ctx.tenant === null, ctx.tenantId === null.
    • defaultTenant: null (explicit) → identical to undefined.
    • defaultTenant: TenantContext → inject verbatim. This preserves pre-foundation-gap-5 behaviour for migrators by setting { id: "public", slug: "public" }.
  • When the route's auth is required AND the resolver chain misses, the pipeline still raises HOST.TENANT_NOT_RESOLVED (400) regardless of defaultTenant — the fallback is a convenience for public routes, not a silent bypass of tenant-required policy.

Downstream pipeline steps now tolerate tenantCtx === null:

  • Rate-limit key falls back to "anon" when no principal AND no tenant is bound.
  • Per-request logger meta carries tenantId: null.
  • succeed() and fail() propagate tenantId: null into PipelineResult.tenantId.
  • PipelineCapabilityResolver.forRequest accepts tenant: TenantContext | null.

RequestContext.tenant / RequestContext.tenantId remain declared as non-nullable in @suaveplan/api-types; handlers consuming tenant-bound capabilities MUST gate on tenant === null first when their host is configured with defaultTenant: null. The contract is documented in host.md and request-pipeline.types.ts.

Migration

Operators relying on the legacy "public" stub MUST set defaultTenant: { id: "public", slug: "public" } in createHost(...). The E2E foundation-stack server has been migrated this way to keep its existing tenantId === "public" assertion green; new deployments SHOULD leave the field unset.