4.5 KiB
@suaveplan/api-tenant-provider — abstract tenant enumeration contract
Package: @suaveplan/api-tenant-provider
Status: PROPOSED
Type: new-package
Stage: 1 (Contracts) — see umbrella openspec/changes/api-plugin-foundation/
Why
perTenant: true cron / queued jobs (design §5.4) need a way to list every tenant the host knows about so the scheduler can fan one job invocation out per tenant, each with a tenant-scoped context. The same enumeration is needed by background reconciliation, per-tenant cache warmers, and admin tooling. Different deployments source tenant lists from different places: an in-memory list (tests + simple setups), a database row, a directory service, a remote API. This package owns the abstract contract — concrete sources plug behind it (api-tenant-provider-memory in Stage 4; production adapters land alongside the host metadata DB or a plugin's own DB).
Per design §5.4: "tenant.provider ships in v1 (in-memory reference impl + adapter slot) so retrofitting tenant enumeration into a stable contract doesn't have to happen later."
tenant.provider is intentionally distinct from tenant.resolver (which extracts tenantId from a request — that contract lives in api-types / consumed by api-tenant-resolver-* adapters).
What changes
Net-new package. Contract + pagination shape + conformance harness.
- ADDED:
./provider— abstractTenantProvider:list(opts?): AsyncIterable<TenantRecord>(stream-friendly for large tenant counts);count(): Promise<number>;lookup(tenantId): Promise<TenantRecord | null>;dispose(): Promise<void>. - ADDED:
./tenant-record—TenantRecord:{ id: TenantId, displayName?: string, status: "active" | "suspended" | "archived", createdAt: string }. Zod schema. Onlyactivetenants are enumerated by default;list({ includeSuspended: true })surfaces those too. - ADDED:
./pagination—ListOpts:{ pageSize?: number, includeSuspended?: boolean, cursor?: string }. TheAsyncIterableMAY be page-driven internally; callers iterate without knowing. - ADDED:
./capability-id— adds"tenant.provider"to theCapabilityIdunion. - ADDED:
./error-codes— frozenTENANT_PROVIDER_CODES:TENANT_NOT_FOUND,SOURCE_UNAVAILABLE,INVALID_CURSOR. - ADDED:
./testing—runTenantProviderConformance(factory)reusable harness.
CapabilityId additions
This package contributes "tenant.provider" to the CapabilityId union. Per-tenant jobs declared in manifest implicitly require this capability (the host resolves it automatically when perTenant: true is set — plugins don't need to list it manually, but it shows up in the registry).
Out of scope
- Concrete sources —
api-tenant-provider-memory(Stage 4); DB-backed sources are deployment-specific. - Tenant CREATE / UPDATE / DELETE — that's an admin concern, not a foundation contract.
- Tenant resolution from a Request — that's
tenant.resolver, contract in@suaveplan/api-types.
Source mapping
Genesis-original. Source of design intent: docs/superpowers/specs/2026-05-13-api-plugin-foundation-design.md.
| Requirement | Design doc section |
|---|---|
perTenant: true fan-out reading from tenant.provider |
§5.4 |
| Provider ships in v1 with in-memory reference impl | §5.4 |
Status filter (active default) |
Implicit from §5.4 (fan-out should not include suspended tenants by default) |
Requirements with no source line
| Requirement | Justification |
|---|---|
AsyncIterable over Array |
Future-proofs against large tenant counts; an array forces full-set load, an iterable streams. Standard genesis pattern. |
runTenantProviderConformance harness |
Stage-5 deliverable (§13.4). |
Verification approach
src/provider/provider.spec-conformance.test.ts— interface shape; iterator semantics.src/tenant-record/tenant-record.test.ts— Zod accept/reject;TenantIdbrand preserved.src/provider/status-filter.test.ts— default excludessuspended/archived;includeSuspended: truesurfaces them.src/pagination/pagination.test.ts—pageSizehonored;cursorround-trips.src/capability-id/capability-id.spec-conformance.test.ts—"tenant.provider"inCapabilityId.tests/dep-graph.test.ts— deps:@suaveplan/api-types,@suaveplan/error,@suaveplan/types,zod.
Migration impact
None — additive only.
Impact
Affected dependents
- (none) — [x] no action needed (reason: net-new; in-memory ref impl lands Stage 4)