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

89 lines
3.6 KiB
Markdown

# 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:
```bash
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}
```