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

3.6 KiB

foundation-fix-gap-2-tenant-chain

  • Status: PROPOSED
  • Spec deltas:
    • ./specs/api-tenant-provider/spec.md
    • ./specs/api-tenant-provider-memory/spec.md

What & why

The post-Stage-4 foundation-coherence audit (Gap #2) flagged a shape divergence between TenantRecord (the inventory row exposed by @suaveplan/api-tenant-provider) and TenantContext (the per-request shape exposed by @suaveplan/api-types/tenant): TenantContext requires slug, but TenantRecord did not carry one. Every TenantProvider-backed resolver was forced to synthesise slug = String(record.id) at the boundary — the e2e foundation server did exactly that, and the api-tenant-resolver-jwt resolver hardcodes slug = tenantId for the same reason.

A second half of the gap: hosts that already have a TenantProvider have to hand-roll the header -> provider.lookup -> TenantContext chain at the start of every fixture or server. No reusable factory existed.

This change closes both halves:

  1. TenantRecord gains required slug: string + optional metadata. Both are mirrored from TenantContext. tenantRecordSchema is updated; every in-tree fixture passes the slug explicitly so the slug = id synthesis bug cannot resurface.
  2. @suaveplan/api-tenant-provider/chain ships tenantResolverChainFromProvider(provider, opts?) + recordToContext(record). Three built-in strategies in fixed order (header / JWT-claim / subdomain), optional LRU memoisation, first-match-wins semantics. Returns a TenantResolverChain conforming to @suaveplan/api-types/tenant-resolver.
  3. e2e/foundation-stack/src/server.ts drops the hand-rolled chain in favour of the factory, and the in-line fixtures gain explicit slugs.

Impact

Affected dependents

  • @suaveplan/api-tenant-provider — TenantRecord gains required slug and optional metadata. Source-incompatible: existing literal-constructed records without slug fail typecheck and schema parse. In-tree consumers are updated as part of this change (memory adapter, e2e fixture, all tests). Adds ./chain subpath export with tenantResolverChainFromProvider + recordToContext.
  • @suaveplan/api-tenant-provider-memory — passes the slug through unchanged; all internal tests updated.
  • @suaveplan/e2e-foundation-stack — buildTenantChain() now wraps the provider via the new factory; in-line fixtures gain slug: "tenant-1" | "tenant-2".
  • @suaveplan/api-tenant-resolver-jwt — unchanged; still synthesises slug = tenantId because the JWT carries only the id, not a slug. Documenting this as deliberate (the JWT resolver has no inventory lookup, so it cannot read slug from a record).

Backward compatibility

TenantRecord.slug is REQUIRED — no v0.1.0 compatibility shim. Genesis is bottom-up rebuild; no shipped consumer constructs TenantRecord outside the packages updated in this change.

tenantResolverChainFromProvider is purely additive: a new subpath export. No existing import path moves.

Parity Checklist

This is a genesis-original fix; no upstream parity table required.

Verification

bunx turbo lint typecheck test build --filter=@suaveplan/api-tenant-provider --filter=@suaveplan/api-tenant-provider-memory --filter=@suaveplan/api-tenant-resolver-jwt → green.

bunx turbo test:coverage --filter=@suaveplan/api-tenant-provider --filter=@suaveplan/api-tenant-provider-memory → 100/100/100/100 statements/branches/functions/lines.

E2E smoke against the new chain:

bun run src/server.ts &
curl -sH 'x-tenant-id: t1' http://127.0.0.1:3500/api/echo/tenanted
# => {"msg":"tenanted","tenantId":"t1","principal":null}